first commit
This commit is contained in:
commit
12e4f17b62
4633 files changed
+817125
No files matched your search
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
export const aiSchema = z.object({
|
||||
provider: z.enum(['OPEN_AI', 'GEMINI']),
|
||||
apiKey: z.string().trim().min(1, { message: 'API Key is required' }),
|
||||
endpointUrl: z.string().trim(),
|
||||
systemPrompt: z.string().trim(),
|
||||
tokenThreshold: z.number().nullable(),
|
||||
notificationThreshold: z.number().nullable(),
|
||||
});
|
||||
|
||||
export const aiTemplateSchema = z.object({
|
||||
title: z.string().trim().min(1).max(30),
|
||||
prompt: z.string().trim().min(1),
|
||||
model: z.string(),
|
||||
temperature: z.number(),
|
||||
});
|
||||
|
||||
export const aiIssueSchema = z.object({
|
||||
channelId: z.number().int(),
|
||||
targetFieldKeys: z.array(z.string()).min(1),
|
||||
prompt: z.string().trim(),
|
||||
isEnabled: z.boolean(),
|
||||
model: z.string(),
|
||||
temperature: z.number(),
|
||||
dataReferenceAmount: z.number(),
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { z } from 'zod';
|
||||
|
||||
import type { aiIssueSchema, aiSchema, aiTemplateSchema } from './ai.schema';
|
||||
|
||||
export type AI = z.infer<typeof aiSchema>;
|
||||
export type AITemplate = z.infer<typeof aiTemplateSchema>;
|
||||
export type AIIssue = z.infer<typeof aiIssueSchema>;
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export * from './ui';
|
||||
export * from './ai.type';
|
||||
export * from './ai.schema';
|
||||
export * from './lib';
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export { default as useCheckAIUsageLimit } from './use-check-ai-usage-limit';
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect } from 'react';
|
||||
import dayjs from 'dayjs';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useLocalStorage } from 'react-use';
|
||||
|
||||
import { Icon, toast } from '@ufb/react';
|
||||
|
||||
import { useOAIQuery } from '@/shared';
|
||||
|
||||
const useCheckAIUsageLimit = (projectId: number) => {
|
||||
const { t } = useTranslation();
|
||||
const { data: aiIntegrations } = useOAIQuery({
|
||||
path: '/api/admin/projects/{projectId}/ai/integrations',
|
||||
variables: { projectId },
|
||||
});
|
||||
const { data: aiUsageLimit } = useOAIQuery({
|
||||
path: '/api/admin/projects/{projectId}/ai/usages',
|
||||
variables: {
|
||||
projectId,
|
||||
from: dayjs().startOf('month').toISOString(),
|
||||
to: dayjs().endOf('month').toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
const [checkedTotalLimitTime, setCheckedTotalLimitTime] =
|
||||
useLocalStorage<string>(
|
||||
`totalLimit-${projectId}-${dayjs().format('YYYY-MM')}`,
|
||||
);
|
||||
const [isCheckedNotificationLimit, setIsCheckedNotificationLimit] =
|
||||
useLocalStorage(
|
||||
`notificationLimit-${projectId}-${dayjs().format('YYYY-MM')}`,
|
||||
false,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!aiIntegrations?.apiKey) return;
|
||||
|
||||
if (aiUsageLimit) {
|
||||
const totalUsage = aiUsageLimit.reduce(
|
||||
(acc, usage) => acc + usage.usedTokens,
|
||||
0,
|
||||
);
|
||||
const totalLimit = aiIntegrations.tokenThreshold;
|
||||
if (!totalLimit) return;
|
||||
if (totalUsage >= totalLimit) {
|
||||
if (
|
||||
!checkedTotalLimitTime ||
|
||||
dayjs(checkedTotalLimitTime).add(1, 'day').isBefore(dayjs())
|
||||
) {
|
||||
setCheckedTotalLimitTime(dayjs().toISOString());
|
||||
const toastId = toast.error(
|
||||
t('v2.toast.ai-function-terminated.title'),
|
||||
{
|
||||
description: t('v2.toast.ai-function-terminated.description'),
|
||||
cancel: {
|
||||
label: <Icon name="RiCloseFill" size={20} />,
|
||||
onClick: () => {
|
||||
toast.dismiss(toastId);
|
||||
},
|
||||
},
|
||||
duration: Infinity,
|
||||
},
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const notificationLimit = aiIntegrations.notificationThreshold;
|
||||
if (!notificationLimit) return;
|
||||
if (totalUsage >= notificationLimit) {
|
||||
if (!isCheckedNotificationLimit) {
|
||||
const toastId = toast.warning(
|
||||
t('v2.toast.ai-usage-limit-approaching.title'),
|
||||
{
|
||||
description: t(
|
||||
'v2.toast.ai-usage-limit-approaching.description',
|
||||
{
|
||||
percentage: Math.round(
|
||||
(notificationLimit / totalLimit) * 100,
|
||||
),
|
||||
},
|
||||
),
|
||||
cancel: {
|
||||
label: <Icon name="RiCloseFill" size={20} />,
|
||||
onClick: () => {
|
||||
toast.dismiss(toastId);
|
||||
},
|
||||
},
|
||||
duration: Infinity,
|
||||
},
|
||||
);
|
||||
setIsCheckedNotificationLimit(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, [aiUsageLimit, aiIntegrations]);
|
||||
};
|
||||
|
||||
export default useCheckAIUsageLimit;
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { memo } from 'react';
|
||||
|
||||
import { Icon } from '@ufb/react';
|
||||
|
||||
import { LoadingBars } from '@/features/update-ai-setting/ui/playground-output-card.ui';
|
||||
|
||||
interface Props {
|
||||
value:
|
||||
| { status: 'loading' | 'success' | 'error'; message: string }
|
||||
| undefined;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
const AICell = memo((props: Props) => {
|
||||
const { value, isLoading } = props;
|
||||
|
||||
if (!value) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{value.status === 'loading' || isLoading ?
|
||||
<LoadingBars width={410} />
|
||||
: value.status === 'error' ?
|
||||
<div className="text-tint-red">
|
||||
<Icon name="RiErrorWarningFill" className="mr-2" size={16} />
|
||||
<span>{value.message}</span>
|
||||
</div>
|
||||
: <p>{value.message}</p>}
|
||||
</>
|
||||
);
|
||||
});
|
||||
export default AICell;
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
|
||||
import { FormField, RadioCard, RadioCardGroup } from '@ufb/react';
|
||||
|
||||
import { FormInput, FormTextarea } from '@/shared/ui/form-inputs';
|
||||
|
||||
import { GeminiIcon, OpenAIIcon } from '@/assets';
|
||||
import type { AI } from '../ai.type';
|
||||
|
||||
const AiSettingForm = () => {
|
||||
const { setValue, watch, control } = useFormContext<AI>();
|
||||
const { t } = useTranslation();
|
||||
const [previousValues, setPreviousValues] = useState<
|
||||
Record<AI['provider'], { apiKey: string; endpointUrl: string }>
|
||||
>({
|
||||
GEMINI: { apiKey: '', endpointUrl: '' },
|
||||
OPEN_AI: { apiKey: '', endpointUrl: '' },
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<RadioCardGroup
|
||||
value={watch('provider')}
|
||||
onValueChange={(currentProvider: 'OPEN_AI' | 'GEMINI') => {
|
||||
const {
|
||||
apiKey: previousApiKey,
|
||||
provider: previousProvider,
|
||||
endpointUrl: previousEndpointUrl,
|
||||
} = watch();
|
||||
|
||||
setPreviousValues((prev) => ({
|
||||
...prev,
|
||||
[previousProvider]: {
|
||||
apiKey: previousApiKey,
|
||||
endpointUrl: previousEndpointUrl,
|
||||
},
|
||||
}));
|
||||
|
||||
setValue('provider', currentProvider, { shouldDirty: true });
|
||||
setValue('apiKey', previousValues[currentProvider].apiKey);
|
||||
setValue('endpointUrl', previousValues[currentProvider].endpointUrl);
|
||||
}}
|
||||
>
|
||||
<RadioCard value="OPEN_AI" icon={<OpenAIIcon />} title="Open AI" />
|
||||
<RadioCard value="GEMINI" icon={<GeminiIcon />} title="Gemini" />
|
||||
</RadioCardGroup>
|
||||
<FormField
|
||||
control={control}
|
||||
name="apiKey"
|
||||
render={({ field }) => (
|
||||
<FormInput
|
||||
label="API Key"
|
||||
placeholder={t('v2.placeholder.text')}
|
||||
{...field}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={control}
|
||||
name="endpointUrl"
|
||||
render={({ field }) => (
|
||||
<FormInput
|
||||
label="Endpoint URL"
|
||||
placeholder={t('v2.placeholder.ai-endpoint-url')}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={control}
|
||||
name="systemPrompt"
|
||||
render={({ field }) => (
|
||||
<FormTextarea
|
||||
label="System Prompt"
|
||||
placeholder={t('v2.placeholder.system-prompt')}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AiSettingForm;
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export { default as AiSettingForm } from './ai-setting-form.ui';
|
||||
export { default as AICell } from './ai-feedback-cell.ui';
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { createColumnHelper } from '@tanstack/react-table';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { Badge } from '@ufb/react';
|
||||
|
||||
import { CopyIconButton, DATE_TIME_FORMAT } from '@/shared';
|
||||
|
||||
import type { ApiKey } from './api-key.type';
|
||||
|
||||
const columnHelper = createColumnHelper<ApiKey>();
|
||||
|
||||
export const getApiKeyColumns = () => [
|
||||
columnHelper.accessor('value', {
|
||||
header: 'API KEY',
|
||||
cell: ({ getValue }) => (
|
||||
<div className="text-base-strong flex items-center">
|
||||
{getValue()}
|
||||
<CopyIconButton data={getValue()} />
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
}),
|
||||
columnHelper.accessor('deletedAt', {
|
||||
header: 'Status',
|
||||
cell: ({ getValue }) => (
|
||||
<Badge color={getValue() === null ? 'green' : 'red'} radius="large">
|
||||
{getValue() === null ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
),
|
||||
enableSorting: false,
|
||||
}),
|
||||
columnHelper.accessor('createdAt', {
|
||||
header: 'Created',
|
||||
cell: ({ getValue }) => dayjs(getValue()).format(DATE_TIME_FORMAT),
|
||||
}),
|
||||
];
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
|
||||
export const apiKeySchema = z.object({
|
||||
id: z.number(),
|
||||
value: z.string(),
|
||||
createdAt: z.string(),
|
||||
deletedAt: z.string().nullable(),
|
||||
});
|
||||
|
||||
export const apiKeyFormSchema = z.object({
|
||||
value: z.string(),
|
||||
status: z.union([z.literal('active'), z.literal('inactive')]),
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import type { z } from 'zod';
|
||||
|
||||
import type { apiKeyFormSchema, apiKeySchema } from './api-key.schema';
|
||||
|
||||
export type ApiKey = z.infer<typeof apiKeySchema>;
|
||||
export type ApiKeyUpdateType = 'recover' | 'softDelete';
|
||||
export type ApiKeyFormSchema = z.infer<typeof apiKeyFormSchema>;
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export * from './ui';
|
||||
export * from './api-key.type';
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import type { FormOverlayProps } from '@/shared';
|
||||
import { FormDialog, SelectInput, TextInput } from '@/shared';
|
||||
|
||||
import { apiKeyFormSchema } from '../api-key.schema';
|
||||
import type { ApiKeyFormSchema } from '../api-key.type';
|
||||
|
||||
interface Props extends FormOverlayProps<ApiKeyFormSchema> {}
|
||||
|
||||
const ApiKeyFormDialog: React.FC<Props> = (props) => {
|
||||
const {
|
||||
data,
|
||||
close,
|
||||
isOpen,
|
||||
onSubmit,
|
||||
onClickDelete,
|
||||
disabledDelete,
|
||||
disabledUpdate,
|
||||
} = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { register, watch, setValue, handleSubmit, formState } =
|
||||
useForm<ApiKeyFormSchema>({
|
||||
resolver: zodResolver(apiKeyFormSchema),
|
||||
defaultValues: data,
|
||||
});
|
||||
|
||||
const { status } = watch();
|
||||
|
||||
return (
|
||||
<FormDialog
|
||||
isOpen={isOpen}
|
||||
close={close}
|
||||
title={t('v2.text.name.detail', { name: 'API Key' })}
|
||||
submitBtn={{ form: 'apiKeyForm', disabled: disabledUpdate }}
|
||||
deleteBtn={{
|
||||
disabled: !!disabledDelete,
|
||||
onClick: onClickDelete,
|
||||
}}
|
||||
formState={formState}
|
||||
>
|
||||
<form
|
||||
id="apiKeyForm"
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
>
|
||||
<TextInput {...register('value')} label="API Key" disabled />
|
||||
<SelectInput
|
||||
label="Status"
|
||||
options={[
|
||||
{ label: 'Active', value: 'active' },
|
||||
{ label: 'Inactive', value: 'inactive' },
|
||||
]}
|
||||
value={status}
|
||||
onChange={(value) =>
|
||||
setValue('status', value as 'active' | 'inactive', {
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</form>
|
||||
</FormDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApiKeyFormDialog;
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table';
|
||||
import { useOverlay } from '@toss/use-overlay';
|
||||
|
||||
import type { EntityTable } from '@/shared';
|
||||
import { BasicTable } from '@/shared';
|
||||
|
||||
import { getApiKeyColumns } from '../api-key-columns';
|
||||
import type { ApiKey, ApiKeyUpdateType } from '../api-key.type';
|
||||
import ApiKeyFormDialog from './api-key-form-dialog.ui';
|
||||
|
||||
interface IProps extends Omit<EntityTable<ApiKey>, 'onClickRow'> {
|
||||
onClickDelete?: (id: number) => Promise<void> | void;
|
||||
onClickUpdate?: (type: ApiKeyUpdateType, id: number) => Promise<void> | void;
|
||||
disabledUpdate?: boolean;
|
||||
}
|
||||
|
||||
const ApiKeyTable: React.FC<IProps> = (props) => {
|
||||
const {
|
||||
isLoading,
|
||||
onClickDelete,
|
||||
onClickUpdate,
|
||||
createButton,
|
||||
data,
|
||||
disabledUpdate,
|
||||
disabledDelete,
|
||||
} = props;
|
||||
|
||||
const overlay = useOverlay();
|
||||
const columns = useMemo(() => getApiKeyColumns(), []);
|
||||
|
||||
const table = useReactTable({
|
||||
columns,
|
||||
data,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
});
|
||||
|
||||
const openApiKeyDialog = (apiKey: ApiKey) => {
|
||||
overlay.open(({ close, isOpen }) => (
|
||||
<ApiKeyFormDialog
|
||||
close={close}
|
||||
isOpen={isOpen}
|
||||
data={{
|
||||
status: apiKey.deletedAt === null ? 'active' : 'inactive',
|
||||
value: apiKey.value,
|
||||
}}
|
||||
onSubmit={async (input) => {
|
||||
await onClickUpdate?.(
|
||||
input.status === 'active' ? 'recover' : 'softDelete',
|
||||
apiKey.id,
|
||||
);
|
||||
close();
|
||||
}}
|
||||
onClickDelete={async () => {
|
||||
await onClickDelete?.(apiKey.id);
|
||||
close();
|
||||
}}
|
||||
disabledDelete={disabledDelete}
|
||||
disabledUpdate={disabledUpdate}
|
||||
/>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<BasicTable
|
||||
isLoading={isLoading}
|
||||
table={table}
|
||||
createButton={createButton}
|
||||
onClickRow={(_, row) => openApiKeyDialog(row)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApiKeyTable;
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export { default as ApiKeyTable } from './api-key-table.ui';
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
export const categorySchema = z.object({
|
||||
id: z.number(),
|
||||
name: z.string().trim(),
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { z } from 'zod';
|
||||
|
||||
import type { categorySchema } from './category.schema';
|
||||
|
||||
export type Category = z.infer<typeof categorySchema>;
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export * from './category.type';
|
||||
export * from './ui';
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export { default as useCategorySearchInfinite } from './use-category-search-infinite';
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
|
||||
import type { OAIMutationResponse, OAIRequestBody } from '@/shared';
|
||||
import { client } from '@/shared';
|
||||
|
||||
type TData = OAIMutationResponse<
|
||||
'/api/admin/projects/{projectId}/categories/search',
|
||||
'post'
|
||||
>;
|
||||
|
||||
interface IBody
|
||||
extends OAIRequestBody<
|
||||
'/api/admin/projects/{projectId}/categories/search',
|
||||
'post'
|
||||
> {}
|
||||
|
||||
const useCategorySearchInfinite = (
|
||||
projectId: number,
|
||||
body: IBody = { limit: 10, page: 1, sort: {} },
|
||||
) => {
|
||||
return useInfiniteQuery<TData>({
|
||||
queryKey: [
|
||||
'/api/admin/projects/{projectId}/categories/search',
|
||||
projectId,
|
||||
body,
|
||||
],
|
||||
queryFn: async ({ pageParam }) => {
|
||||
const { data: result } = await client.post({
|
||||
path: '/api/admin/projects/{projectId}/categories/search',
|
||||
pathParams: { projectId },
|
||||
body: { ...body, page: pageParam as number },
|
||||
});
|
||||
return result;
|
||||
},
|
||||
getNextPageParam: (lastPage) => {
|
||||
if (!lastPage) return undefined;
|
||||
if (lastPage.meta.currentPage < lastPage.meta.totalPages) {
|
||||
return lastPage.meta.currentPage + 1;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
initialPageParam: 1,
|
||||
initialData: {
|
||||
pageParams: [],
|
||||
pages: [
|
||||
{
|
||||
items: [],
|
||||
meta: {
|
||||
currentPage: 1,
|
||||
totalPages: 0,
|
||||
totalItems: 0,
|
||||
itemCount: 0,
|
||||
itemsPerPage: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default useCategorySearchInfinite;
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import {
|
||||
Badge,
|
||||
Dropdown,
|
||||
DropdownContent,
|
||||
DropdownItem,
|
||||
DropdownTrigger,
|
||||
Icon,
|
||||
TextInput,
|
||||
toast,
|
||||
} from '@ufb/react';
|
||||
|
||||
import { client } from '@/shared';
|
||||
import type { Category } from '@/entities/category';
|
||||
|
||||
interface Props {
|
||||
projectId: number;
|
||||
cateogry: Category;
|
||||
}
|
||||
|
||||
const CategoryComboboxEditPopover = (props: Props) => {
|
||||
const { projectId, cateogry } = props;
|
||||
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const refetch = async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['/api/admin/projects/{projectId}/categories/search'],
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['/api/admin/projects/{projectId}/issues/search'],
|
||||
});
|
||||
};
|
||||
|
||||
const [inputValue, setInputValue] = useState(cateogry.name);
|
||||
|
||||
const { mutate: updateCategory } = useMutation({
|
||||
mutationFn: async ({ name }: { name: string }) => {
|
||||
const { data } = await client.put({
|
||||
path: '/api/admin/projects/{projectId}/categories/{categoryId}',
|
||||
pathParams: { projectId, categoryId: cateogry.id },
|
||||
body: { name },
|
||||
});
|
||||
return data;
|
||||
},
|
||||
async onSuccess() {
|
||||
await refetch();
|
||||
toast.success(t('v2.toast.success'));
|
||||
},
|
||||
onError(error) {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
const { mutate: deleteCategory } = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { data } = await client.delete({
|
||||
path: '/api/admin/projects/{projectId}/categories/{categoryId}',
|
||||
pathParams: { projectId, categoryId: cateogry.id },
|
||||
});
|
||||
return data;
|
||||
},
|
||||
async onSuccess() {
|
||||
await refetch();
|
||||
toast.success(t('v2.toast.success'));
|
||||
},
|
||||
onError(error) {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
return (
|
||||
<Dropdown>
|
||||
<DropdownTrigger asChild>
|
||||
<Badge variant="subtle" onClick={(e) => e.stopPropagation()}>
|
||||
Edit
|
||||
</Badge>
|
||||
</DropdownTrigger>
|
||||
<DropdownContent onClick={(e) => e.stopPropagation()} side="right">
|
||||
<div className="border-b">
|
||||
<TextInput
|
||||
className="border-none"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.stopPropagation();
|
||||
const name = inputValue.trim();
|
||||
updateCategory({ name });
|
||||
setInputValue(name);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<DropdownItem
|
||||
className="gap-2"
|
||||
onSelect={() => deleteCategory(undefined)}
|
||||
>
|
||||
<Icon name="RiDeleteBin5Fill" size={16} />
|
||||
Delete Category
|
||||
</DropdownItem>
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default CategoryComboboxEditPopover;
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useThrottle } from 'react-use';
|
||||
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
ComboboxGroup,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
ComboboxTrigger,
|
||||
toast,
|
||||
} from '@ufb/react';
|
||||
|
||||
import {
|
||||
client,
|
||||
cn,
|
||||
commandFilter,
|
||||
InfiniteScrollArea,
|
||||
usePermissions,
|
||||
} from '@/shared';
|
||||
import type { Category } from '@/entities/category';
|
||||
|
||||
import { useCategorySearchInfinite } from '../lib';
|
||||
import CategoryComboboxEditPopover from './category-combobox-edit-popover.ui';
|
||||
|
||||
interface Props extends React.PropsWithChildren {
|
||||
category?: Category | null;
|
||||
issueId: number;
|
||||
}
|
||||
const CategoryCombobox = (props: Props) => {
|
||||
const { category, issueId, children } = props;
|
||||
|
||||
const perms = usePermissions();
|
||||
const router = useRouter();
|
||||
const projectId = Number(router.query.projectId);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const throttledvalue = useThrottle(inputValue, 500);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
|
||||
useCategorySearchInfinite(Number(projectId), {
|
||||
limit: 10,
|
||||
categoryName: throttledvalue,
|
||||
sort: { name: 'ASC' },
|
||||
});
|
||||
|
||||
const allcategories = useMemo(() => {
|
||||
return data.pages
|
||||
.map((v) => v?.items)
|
||||
.filter((v) => !!v)
|
||||
.flat();
|
||||
}, [data]);
|
||||
|
||||
const refetch = async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['/api/admin/projects/{projectId}/categories/search'],
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['/api/admin/projects/{projectId}/issues/search'],
|
||||
});
|
||||
};
|
||||
|
||||
const { mutate: attachCategory } = useMutation({
|
||||
mutationFn: async ({ categoryId }: { categoryId: number }) => {
|
||||
const { data } = await client.put({
|
||||
path: '/api/admin/projects/{projectId}/issues/{issueId}/category/{categoryId}',
|
||||
pathParams: { projectId, issueId, categoryId },
|
||||
});
|
||||
return data;
|
||||
},
|
||||
async onSuccess() {
|
||||
await refetch();
|
||||
toast.success(t('v2.toast.success'));
|
||||
},
|
||||
onError(error) {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
const { mutate: detachCategory } = useMutation({
|
||||
mutationFn: async ({ categoryId }: { categoryId: number }) => {
|
||||
const { data } = await client.delete({
|
||||
path: '/api/admin/projects/{projectId}/issues/{issueId}/category/{categoryId}',
|
||||
pathParams: { projectId, issueId, categoryId },
|
||||
});
|
||||
return data;
|
||||
},
|
||||
async onSuccess() {
|
||||
await refetch();
|
||||
toast.success(t('v2.toast.success'));
|
||||
},
|
||||
onError(error) {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: createCategory } = useMutation({
|
||||
mutationFn: async ({ name }: { name: string }) => {
|
||||
const { data } = await client.post({
|
||||
path: '/api/admin/projects/{projectId}/categories',
|
||||
pathParams: { projectId },
|
||||
body: { name },
|
||||
});
|
||||
return data;
|
||||
},
|
||||
async onSuccess() {
|
||||
await refetch();
|
||||
setInputValue('');
|
||||
toast.success(t('v2.toast.success'));
|
||||
},
|
||||
onError(error) {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Combobox>
|
||||
<ComboboxTrigger asChild>
|
||||
<button
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
disabled={!perms.includes('issue_update')}
|
||||
className={cn(
|
||||
{ 'opacity-50': !perms.includes('issue_update') },
|
||||
'w-fit',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</ComboboxTrigger>
|
||||
<ComboboxContent options={{ filter: commandFilter }}>
|
||||
<ComboboxInput
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onValueChange={(value) => setInputValue(value)}
|
||||
value={inputValue}
|
||||
/>
|
||||
<ComboboxList onClick={(e) => e.stopPropagation()} maxHeight="200px">
|
||||
<ComboboxGroup
|
||||
heading={
|
||||
<span className="text-neutral-tertiary text-base-normal">
|
||||
Selected Category
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{allcategories
|
||||
.filter((v) => category?.id === v.id)
|
||||
.map(({ id, name }) => (
|
||||
<ComboboxItem
|
||||
key={id}
|
||||
value={name}
|
||||
onSelect={() => {
|
||||
if (category?.id === id) detachCategory({ categoryId: id });
|
||||
if (category?.id !== id) attachCategory({ categoryId: id });
|
||||
}}
|
||||
>
|
||||
<span className="flex-1">{name}</span>
|
||||
<span className="text-neutral-tertiary text-small-normal">
|
||||
Remove
|
||||
</span>
|
||||
</ComboboxItem>
|
||||
))}
|
||||
</ComboboxGroup>
|
||||
<ComboboxGroup
|
||||
heading={
|
||||
<span className="text-neutral-tertiary text-base-normal">
|
||||
Category List
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{allcategories
|
||||
.filter((v) => category?.id !== v.id)
|
||||
.map(({ id, name }) => (
|
||||
<ComboboxItem
|
||||
key={id}
|
||||
value={name}
|
||||
onSelect={() => {
|
||||
if (category?.id === id) detachCategory({ categoryId: id });
|
||||
if (category?.id !== id) attachCategory({ categoryId: id });
|
||||
}}
|
||||
>
|
||||
<span className="flex-1">{name}</span>
|
||||
<CategoryComboboxEditPopover
|
||||
projectId={projectId}
|
||||
cateogry={{ id, name }}
|
||||
/>
|
||||
</ComboboxItem>
|
||||
))}
|
||||
<InfiniteScrollArea
|
||||
fetchNextPage={fetchNextPage}
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
/>
|
||||
</ComboboxGroup>
|
||||
{!!inputValue &&
|
||||
perms.includes('issue_update') &&
|
||||
!allcategories.some((v) => v.name === inputValue) && (
|
||||
<div
|
||||
className="combobox-item"
|
||||
onClick={async () => {
|
||||
const name = inputValue.trim();
|
||||
const data = await createCategory({ name });
|
||||
if (!data) return;
|
||||
setInputValue(name);
|
||||
attachCategory({ categoryId: data.id });
|
||||
}}
|
||||
>
|
||||
<span className="flex-1">{inputValue}</span>
|
||||
<span className="text-neutral-tertiary text-small-normal">
|
||||
Create
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
);
|
||||
};
|
||||
|
||||
export default CategoryCombobox;
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export { default as CategoryCombobox } from './category-combobox.ui';
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { i18n } from 'next-i18next';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const channelImageConfigSchema = z.object({
|
||||
accessKeyId: z.string().trim(),
|
||||
secretAccessKey: z.string().trim(),
|
||||
endpoint: z.string().trim(),
|
||||
region: z.string().trim(),
|
||||
bucket: z.string().trim(),
|
||||
domainWhiteList: z.array(z.string().trim()).nullable(),
|
||||
enablePresignedUrlDownload: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const channelSchema = z.object({
|
||||
id: z.number(),
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, { message: i18n?.t('hint.required') })
|
||||
.max(20, { message: i18n?.t('hint.max-length', { length: 20 }) }),
|
||||
description: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(50, { message: i18n?.t('hint.max-length', { length: 50 }) })
|
||||
.nullable(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
imageConfig: channelImageConfigSchema.nullable().optional(),
|
||||
feedbackSearchMaxDays: z.number(),
|
||||
});
|
||||
|
||||
export const channelInfoSchema = channelSchema
|
||||
.pick({ name: true, description: true, feedbackSearchMaxDays: true })
|
||||
.merge(z.object({ id: z.number().optional() }));
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import type { z } from 'zod';
|
||||
|
||||
import type {
|
||||
channelImageConfigSchema,
|
||||
channelInfoSchema,
|
||||
channelSchema,
|
||||
} from './channel.schema';
|
||||
|
||||
export type Channel = z.infer<typeof channelSchema>;
|
||||
export type ChannelInfo = z.infer<typeof channelInfoSchema>;
|
||||
export type ChannelImageConfig = z.infer<typeof channelImageConfigSchema>;
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export * from './channel.type';
|
||||
export * from './channel.schema';
|
||||
export * from './ui';
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export { default as useRoutingChannelCreation } from './use-routing-channel-creation';
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useRouter } from 'next/router';
|
||||
import { useOverlay } from '@toss/use-overlay';
|
||||
|
||||
import { CreatingDialog, Path } from '@/shared';
|
||||
import { useCreateChannelStore } from '@/features/create-channel';
|
||||
|
||||
const useRoutingChannelCreation = (projectId: number) => {
|
||||
const { editingStepIndex, reset, jumpStepByIndex } = useCreateChannelStore();
|
||||
const overlay = useOverlay();
|
||||
const router = useRouter();
|
||||
|
||||
const openChannelInProgress = async () => {
|
||||
if (editingStepIndex !== null) {
|
||||
await new Promise<boolean>((resolve) =>
|
||||
overlay.open(({ close, isOpen }) => (
|
||||
<CreatingDialog
|
||||
isOpen={isOpen}
|
||||
close={close}
|
||||
type="Channel"
|
||||
onRestart={() => {
|
||||
reset();
|
||||
resolve(true);
|
||||
}}
|
||||
onContinue={() => {
|
||||
jumpStepByIndex(editingStepIndex);
|
||||
resolve(true);
|
||||
}}
|
||||
/>
|
||||
)),
|
||||
);
|
||||
}
|
||||
await router.push({ pathname: Path.CREATE_CHANNEL, query: { projectId } });
|
||||
};
|
||||
|
||||
const isCreatingChannel = editingStepIndex !== null;
|
||||
return {
|
||||
openChannelInProgress,
|
||||
isCreatingChannel,
|
||||
};
|
||||
};
|
||||
|
||||
export default useRoutingChannelCreation;
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
|
||||
import { SelectInput, TextInput } from '@/shared';
|
||||
|
||||
import type { ChannelInfo } from '../channel.type';
|
||||
|
||||
interface IProps {
|
||||
type?: 'create' | 'update';
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
const ChannelInfoForm: React.FC<IProps> = (props) => {
|
||||
const { type = 'create', readOnly = false } = props;
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { register, formState, setValue, watch } =
|
||||
useFormContext<ChannelInfo>();
|
||||
|
||||
const feedbackSearchMaxDays = watch('feedbackSearchMaxDays');
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{type === 'update' && (
|
||||
<TextInput label="ID" {...register('id')} disabled />
|
||||
)}
|
||||
<TextInput
|
||||
label="Name"
|
||||
{...register('name')}
|
||||
placeholder={t('v2.placeholder.text')}
|
||||
required
|
||||
disabled={readOnly}
|
||||
error={formState.errors.name?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label="Description"
|
||||
{...register('description')}
|
||||
placeholder={t('v2.placeholder.text')}
|
||||
disabled={readOnly}
|
||||
error={formState.errors.description?.message}
|
||||
/>
|
||||
<SelectInput
|
||||
label="Maximum feedback search period"
|
||||
value={
|
||||
feedbackSearchMaxDays ? String(feedbackSearchMaxDays) : undefined
|
||||
}
|
||||
onChange={(value) => {
|
||||
if (!value) return;
|
||||
setValue('feedbackSearchMaxDays', Number(value), {
|
||||
shouldDirty: true,
|
||||
});
|
||||
}}
|
||||
options={[
|
||||
{ value: '30', label: t('text.date.before-days', { day: 30 }) },
|
||||
{ value: '90', label: t('text.date.before-days', { day: 90 }) },
|
||||
{ value: '180', label: t('text.date.before-days', { day: 180 }) },
|
||||
{ value: '365', label: t('text.date.before-days', { day: 365 }) },
|
||||
{ value: '-1', label: t('text.date.entire-period') },
|
||||
]}
|
||||
disabled={readOnly}
|
||||
error={formState.errors.feedbackSearchMaxDays?.message}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelInfoForm;
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
|
||||
import { Badge, Button, Divider, Icon, toast } from '@ufb/react';
|
||||
|
||||
import {
|
||||
ComboboxInputbox,
|
||||
SelectInput,
|
||||
TextInput,
|
||||
useOAIMutation,
|
||||
} from '@/shared';
|
||||
|
||||
import type { ChannelImageConfig } from '../channel.type';
|
||||
|
||||
interface IProps {
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
const ImageConfigForm: React.FC<IProps> = (props) => {
|
||||
const { readOnly = false } = props;
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [inputDomain, setInputDomain] = useState('');
|
||||
const [connectionError, setConnectionError] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
formState,
|
||||
watch,
|
||||
setValue,
|
||||
setError,
|
||||
clearErrors,
|
||||
getValues,
|
||||
} = useFormContext<ChannelImageConfig>();
|
||||
|
||||
const domainWhiteList = watch('domainWhiteList');
|
||||
|
||||
const addDomainWhiteList = (value: string) => {
|
||||
if (domainWhiteList?.includes(value)) {
|
||||
setError('domainWhiteList', {
|
||||
message: t('hint.name-already-exists', { name: 'Domain' }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/[a-z]+\.[a-z]{2,3}/.test(value)) {
|
||||
setError('domainWhiteList', { message: t('hint.invalid-domain') });
|
||||
return;
|
||||
}
|
||||
|
||||
setValue('domainWhiteList', (domainWhiteList ?? []).concat(value), {
|
||||
shouldDirty: true,
|
||||
});
|
||||
clearErrors('domainWhiteList');
|
||||
setInputDomain('');
|
||||
};
|
||||
|
||||
const removeDomainWhiteList = (index: number) => {
|
||||
if (!domainWhiteList) return;
|
||||
setValue(
|
||||
'domainWhiteList',
|
||||
domainWhiteList.filter((_, i) => i !== index),
|
||||
{ shouldDirty: true },
|
||||
);
|
||||
};
|
||||
const { mutate: testConection } = useOAIMutation({
|
||||
method: 'post',
|
||||
path: '/api/admin/projects/{projectId}/channels/image-upload-url-test',
|
||||
pathParams: { projectId: 0 },
|
||||
queryOptions: {
|
||||
onSuccess(data) {
|
||||
if (data?.success) {
|
||||
toast.success(t('v2.toast.success'));
|
||||
} else {
|
||||
setError('accessKeyId', { message: '' });
|
||||
setError('bucket', { message: '' });
|
||||
setError('endpoint', { message: '' });
|
||||
setError('region', { message: '' });
|
||||
setError('root', { message: '' });
|
||||
setError('secretAccessKey', { message: '' });
|
||||
toast.error('Test Connection failed');
|
||||
setConnectionError(false);
|
||||
setConnectionError(true);
|
||||
}
|
||||
},
|
||||
onError() {
|
||||
toast.error('Test Connection failed');
|
||||
setConnectionError(true);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const handleTestConnection = () => {
|
||||
let isError = false;
|
||||
const { accessKeyId, bucket, endpoint, region, secretAccessKey } =
|
||||
getValues();
|
||||
if (accessKeyId.length === 0) {
|
||||
setError('accessKeyId', { message: t('hint.required') });
|
||||
isError = true;
|
||||
}
|
||||
if (bucket.length === 0) {
|
||||
setError('bucket', { message: t('hint.required') });
|
||||
isError = true;
|
||||
}
|
||||
if (endpoint.length === 0) {
|
||||
setError('endpoint', { message: t('hint.required') });
|
||||
isError = true;
|
||||
}
|
||||
if (region.length === 0) {
|
||||
setError('region', { message: t('hint.required') });
|
||||
isError = true;
|
||||
}
|
||||
if (secretAccessKey.length === 0) {
|
||||
setError('secretAccessKey', { message: t('hint.required') });
|
||||
isError = true;
|
||||
}
|
||||
if (isError) {
|
||||
setConnectionError(true);
|
||||
return;
|
||||
}
|
||||
testConection(getValues());
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="border-neutral-tertiary mb-4 flex flex-col gap-6 rounded border p-4">
|
||||
<h5 className="text-title-h5">
|
||||
{t('title-box.image-storage-integration')}
|
||||
</h5>
|
||||
<div className="flex flex-col gap-4">
|
||||
<TextInput
|
||||
label="Access Key ID"
|
||||
placeholder={t('v2.placeholder.text')}
|
||||
{...register('accessKeyId')}
|
||||
error={formState.errors.accessKeyId?.message}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
<TextInput
|
||||
{...register('secretAccessKey')}
|
||||
label="Secret Access Key"
|
||||
placeholder={t('v2.placeholder.text')}
|
||||
error={formState.errors.secretAccessKey?.message}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
<TextInput
|
||||
{...register('endpoint')}
|
||||
label="End Point"
|
||||
placeholder={t('v2.placeholder.text')}
|
||||
error={formState.errors.endpoint?.message}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
<TextInput
|
||||
{...register('region')}
|
||||
label="Region"
|
||||
placeholder={t('v2.placeholder.text')}
|
||||
error={formState.errors.region?.message}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
<TextInput
|
||||
{...register('bucket')}
|
||||
label="Bucket Name"
|
||||
placeholder={t('v2.placeholder.text')}
|
||||
error={formState.errors.bucket?.message}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
<SelectInput
|
||||
options={[
|
||||
{ label: 'Enable', value: 'true' },
|
||||
{ label: 'Disable', value: 'false' },
|
||||
]}
|
||||
value={
|
||||
typeof watch('enablePresignedUrlDownload') !== 'undefined' ?
|
||||
String(watch('enablePresignedUrlDownload'))
|
||||
: ''
|
||||
}
|
||||
placeholder={t('v2.placeholder.select')}
|
||||
onChange={(v) =>
|
||||
setValue('enablePresignedUrlDownload', v === 'true', {
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
label="Presigned URL Download"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={handleTestConnection}
|
||||
>
|
||||
Test Connection
|
||||
</Button>
|
||||
{connectionError && (
|
||||
<div className="text-tint-red flex items-center gap-1">
|
||||
<Icon name="RiErrorWarningFill" size={20} />
|
||||
<span>{t('v2.text.test-connection-failed')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-neutral-tertiary flex flex-col gap-2 rounded border p-4">
|
||||
<h5 className="text-title-h5">Image URL Domain Whitelist</h5>
|
||||
<div className="flex gap-4">
|
||||
<ComboboxInputbox
|
||||
clearError={() => clearErrors('domainWhiteList')}
|
||||
inputValue={inputDomain}
|
||||
setInputValue={setInputDomain}
|
||||
onSelectValue={addDomainWhiteList}
|
||||
error={formState.errors.domainWhiteList?.message}
|
||||
placeholder="example.com"
|
||||
>
|
||||
Whitelist
|
||||
</ComboboxInputbox>
|
||||
<Divider orientation="vertical" className="h-5" variant="subtle" />
|
||||
<div className="flex items-center gap-2">
|
||||
{!domainWhiteList || domainWhiteList.length === 0 ?
|
||||
<p className="text-neutral-tertiary">
|
||||
{t('v2.text.all-image-url-allow')}
|
||||
</p>
|
||||
: domainWhiteList.map((domain, index) => (
|
||||
<Badge
|
||||
key={index}
|
||||
className="flex items-center"
|
||||
variant="subtle"
|
||||
>
|
||||
{domain}
|
||||
<Icon
|
||||
name="RiCloseLine"
|
||||
onClick={() => removeDomainWhiteList(index)}
|
||||
size={16}
|
||||
/>
|
||||
</Badge>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageConfigForm;
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export { default as ChannelInfoForm } from './channel-info-form.ui';
|
||||
export { default as ImageConfigForm } from './image-config-form.ui';
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export * from './ui';
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export { default as useLineChartData } from './use-line-chart-data';
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useMemo } from 'react';
|
||||
import dayjs from 'dayjs';
|
||||
import minMax from 'dayjs/plugin/minMax';
|
||||
|
||||
import { CHART_FIVE_COLORS, getDayCount } from '@/shared';
|
||||
|
||||
dayjs.extend(minMax);
|
||||
|
||||
const getDarkColor = () => {
|
||||
return (
|
||||
'#' +
|
||||
Array.from({ length: 6 })
|
||||
.map(() => Math.floor(Math.random() * 16).toString(16))
|
||||
.join('')
|
||||
);
|
||||
};
|
||||
|
||||
const useLineChartData = (
|
||||
from: Date,
|
||||
to: Date,
|
||||
targetData: { id: number; name: string }[],
|
||||
data?: {
|
||||
id: number;
|
||||
statistics: { startDate: string; endDate: string; count: number }[];
|
||||
}[],
|
||||
) => {
|
||||
const dataKeys = useMemo(
|
||||
() =>
|
||||
targetData
|
||||
.sort((a, b) => a.id - b.id)
|
||||
.map(({ name }, i) => ({
|
||||
color: CHART_FIVE_COLORS[i] ?? getDarkColor(),
|
||||
name,
|
||||
})),
|
||||
[targetData],
|
||||
);
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
if (!data) return [];
|
||||
|
||||
const result = [];
|
||||
|
||||
let currentDate = dayjs(to).endOf('day');
|
||||
const startDate = dayjs(from).startOf('day');
|
||||
|
||||
const dayCount = getDayCount(from, to);
|
||||
|
||||
while (currentDate.isAfter(startDate)) {
|
||||
const prevDate = dayjs.max(
|
||||
currentDate.subtract(dayCount > 50 ? 6 : 0, 'day'),
|
||||
dayjs(from),
|
||||
);
|
||||
|
||||
const channelData = targetData.reduce(
|
||||
(acc, cur) => {
|
||||
const currentData = data.find((v) => v.id === cur.id);
|
||||
const count =
|
||||
currentData?.statistics.find(
|
||||
(v) => v.endDate === currentDate.format('YYYY-MM-DD'),
|
||||
)?.count ?? 0;
|
||||
return { ...acc, [cur.name]: count };
|
||||
},
|
||||
{
|
||||
date:
|
||||
dayCount > 50 ?
|
||||
`${prevDate.format('MM/DD')} - ${currentDate.format('MM/DD')}`
|
||||
: currentDate.format('MM/DD'),
|
||||
},
|
||||
);
|
||||
|
||||
result.push(channelData);
|
||||
|
||||
currentDate = prevDate.subtract(1, 'day');
|
||||
}
|
||||
return result.reverse();
|
||||
}, [data, targetData]);
|
||||
|
||||
return { chartData, dataKeys };
|
||||
};
|
||||
|
||||
export default useLineChartData;
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import dayjs from 'dayjs';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { DashboardCard, useOAIQuery } from '@/shared';
|
||||
|
||||
interface IProps {
|
||||
projectId: number;
|
||||
from: Date;
|
||||
to: Date;
|
||||
}
|
||||
|
||||
const CreateFeedbackPerIssueCard: React.FC<IProps> = (props) => {
|
||||
const { from, to, projectId } = props;
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data } = useOAIQuery({
|
||||
path: '/api/admin/statistics/feedback/issued-ratio',
|
||||
variables: { from: from.toISOString(), to: to.toISOString(), projectId },
|
||||
queryOptions: {
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchInterval: false,
|
||||
},
|
||||
});
|
||||
return (
|
||||
<DashboardCard
|
||||
data={`${parseFloat(Math.abs((data?.ratio ?? 0) * 100).toFixed(1))}%`}
|
||||
title={t('dashboard-card.issue-ratio.title')}
|
||||
description={`${t('dashboard-card.issue-ratio.description', {
|
||||
targetDate: `${dayjs(from).format('YYYY/MM/DD')} - ${dayjs(to).format(
|
||||
'YYYY/MM/DD',
|
||||
)}`,
|
||||
})}`}
|
||||
type="issue"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateFeedbackPerIssueCard;
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import dayjs from 'dayjs';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
ComboboxList,
|
||||
ComboboxSelectItem,
|
||||
ComboboxTrigger,
|
||||
Icon,
|
||||
} from '@ufb/react';
|
||||
|
||||
import {
|
||||
ChartCard,
|
||||
Legend,
|
||||
LineChart,
|
||||
useAllChannels,
|
||||
useOAIQuery,
|
||||
} from '@/shared';
|
||||
import type { Channel } from '@/entities/channel';
|
||||
|
||||
import { useLineChartData } from '../lib';
|
||||
|
||||
interface IProps {
|
||||
projectId: number;
|
||||
from: Date;
|
||||
to: Date;
|
||||
}
|
||||
const FeedbackLineChartWrapper: React.FC<IProps> = (props) => {
|
||||
const { from, projectId, to } = props;
|
||||
const { data: channels } = useAllChannels(projectId);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [currentChannels, setCurrentChannels] = useState<Channel[]>([]);
|
||||
const dayCount = useMemo(() => dayjs(to).diff(from, 'day') + 1, [from, to]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentChannels(channels?.items.slice(0, 5) ?? []);
|
||||
}, [channels]);
|
||||
|
||||
const { data } = useOAIQuery({
|
||||
path: '/api/admin/statistics/feedback',
|
||||
variables: {
|
||||
startDate: dayjs(from).startOf('day').format('YYYY-MM-DD'),
|
||||
endDate: dayjs(to).endOf('day').format('YYYY-MM-DD'),
|
||||
channelIds: currentChannels.map(({ id }) => id).join(','),
|
||||
interval: dayCount > 50 ? 'week' : 'day',
|
||||
},
|
||||
queryOptions: {
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchInterval: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { chartData, dataKeys } = useLineChartData(
|
||||
from,
|
||||
to,
|
||||
currentChannels,
|
||||
data?.channels ?? [],
|
||||
);
|
||||
|
||||
return (
|
||||
<ChartCard
|
||||
title={t('chart.feedback-trend.title')}
|
||||
description={`${t('chart.feedback-trend.description')} (${dayjs(
|
||||
from,
|
||||
).format('YYYY/MM/DD')} - ${dayjs(to).format('YYYY/MM/DD')})`}
|
||||
extra={
|
||||
<div className="flex gap-3">
|
||||
<Legend dataKeys={dataKeys} />
|
||||
<ChannelSelectCombobox
|
||||
currentChannels={currentChannels}
|
||||
setCurrentChannels={setCurrentChannels}
|
||||
channels={channels}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LineChart height={400} dataKeys={dataKeys} data={chartData} />
|
||||
</ChartCard>
|
||||
);
|
||||
};
|
||||
|
||||
interface ChannelSelectComboboxProps {
|
||||
channels?: { items: Channel[] };
|
||||
currentChannels: Channel[];
|
||||
setCurrentChannels: React.Dispatch<React.SetStateAction<Channel[]>>;
|
||||
}
|
||||
|
||||
const ChannelSelectCombobox = (props: ChannelSelectComboboxProps) => {
|
||||
const { channels, currentChannels, setCurrentChannels } = props;
|
||||
return (
|
||||
<Combobox>
|
||||
<ComboboxTrigger>
|
||||
<Icon name="RiFilter3Line" />
|
||||
Filter
|
||||
</ComboboxTrigger>
|
||||
<ComboboxContent>
|
||||
<ComboboxList maxHeight="200px">
|
||||
{channels?.items.map((channel) => (
|
||||
<ComboboxSelectItem
|
||||
key={channel.id}
|
||||
value={String(channel.id)}
|
||||
checked={currentChannels.some(({ id }) => id === channel.id)}
|
||||
onSelect={() => {
|
||||
const isChecked = currentChannels.some(
|
||||
({ id }) => id === channel.id,
|
||||
);
|
||||
setCurrentChannels((prev) =>
|
||||
isChecked ? prev.filter(({ id }) => id !== channel.id)
|
||||
: prev.length === 5 ? [...prev.slice(1), channel]
|
||||
: [...prev, channel],
|
||||
);
|
||||
}}
|
||||
>
|
||||
{channel.name}
|
||||
</ComboboxSelectItem>
|
||||
))}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeedbackLineChartWrapper;
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export { default as IssueRank } from './issue-rank.ui';
|
||||
|
||||
export { default as TotalFeedbackCard } from './total-feedback-card.ui';
|
||||
export { default as TotalIssueCard } from './total-issue-card.ui';
|
||||
|
||||
export { default as CreateFeedbackPerIssueCard } from './create-feedback-per-issue-card.ui';
|
||||
|
||||
export { default as TodayFeedbackCard } from './today-feedback-card.ui';
|
||||
export { default as YesterdayFeedbackCard } from './yesterday-feedback-card.ui';
|
||||
export { default as SevenDaysFeedbackCard } from './seven-days-feedback-card.ui';
|
||||
export { default as ThirtyDaysFeedbackCard } from './thirty-days-feedback-card.ui';
|
||||
|
||||
export { default as TodayIssueCard } from './today-issue-card.ui';
|
||||
export { default as YesterdayIssueCard } from './yesterday-issue-card.ui';
|
||||
export { default as SevenDaysIssueCard } from './seven-days-issue-card.ui';
|
||||
export { default as ThirtyDaysIssueCard } from './thirty-days-issue-card.ui';
|
||||
|
||||
export { default as IssueLineChart } from './issue-line-chart.ui';
|
||||
|
||||
export { default as IssueBarChart } from './issue-bar-chart.ui';
|
||||
|
||||
export { default as IssueFeedbackLineChart } from './issue-feedback-line-chart.ui';
|
||||
export { default as FeedbackLineChart } from './feedback-line-chart.ui';
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { ToggleGroup, ToggleGroupItem } from '@ufb/react';
|
||||
|
||||
import {
|
||||
BarChart,
|
||||
ChartCard,
|
||||
ISSUES,
|
||||
Path,
|
||||
PieChart,
|
||||
useOAIQuery,
|
||||
} from '@/shared';
|
||||
import type { IssueStatus } from '@/entities/issue';
|
||||
|
||||
interface IProps {
|
||||
projectId: number;
|
||||
from: Date;
|
||||
to: Date;
|
||||
}
|
||||
const COLOR_MAP: Record<IssueStatus, string> = {
|
||||
INIT: '#EAB308',
|
||||
IN_PROGRESS: '#0EA5E9',
|
||||
ON_REVIEW: '#22C55E',
|
||||
PENDING: '#6366F1',
|
||||
RESOLVED: '#71717A',
|
||||
};
|
||||
|
||||
const IssueBarChart: React.FC<IProps> = ({ projectId }) => {
|
||||
const { t } = useTranslation();
|
||||
const [type, setType] = useState('bar');
|
||||
|
||||
const { data: statisticsData } = useOAIQuery({
|
||||
path: '/api/admin/statistics/issue/count-by-status',
|
||||
variables: { projectId },
|
||||
queryOptions: {
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchInterval: false,
|
||||
},
|
||||
});
|
||||
const onChangeType = (type: 'bar' | 'pie') => (v: string) => {
|
||||
setType(v === '' ? type : v);
|
||||
};
|
||||
const data = ISSUES(t).map(({ key, name }) => ({
|
||||
name,
|
||||
value: +(
|
||||
statisticsData?.statistics.find((v) => v.status === key)?.count ?? 0
|
||||
),
|
||||
color: COLOR_MAP[key],
|
||||
}));
|
||||
const onClickIssue = (name: string | undefined) => {
|
||||
if (!name) return;
|
||||
const issue = ISSUES(t).find((v) => v.name === name);
|
||||
window.open(
|
||||
Path.ISSUE.replace('[projectId]', projectId.toString()) +
|
||||
'?queries=' +
|
||||
JSON.stringify([{ key: 'status', value: issue?.key, condition: 'IS' }]),
|
||||
'_blank',
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ChartCard
|
||||
title={t('chart.issue-status-count.title')}
|
||||
description={t('chart.issue-status-count.description')}
|
||||
extra={
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={type}
|
||||
onValueChange={onChangeType('pie')}
|
||||
>
|
||||
<ToggleGroupItem value="bar">Bar</ToggleGroupItem>
|
||||
<ToggleGroupItem value="pie">Pie</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
}
|
||||
>
|
||||
{type === 'pie' && (
|
||||
<div className="flex items-center">
|
||||
<PieChart data={data} onClick={onClickIssue} height={415} />
|
||||
<IssueLegend data={data} />
|
||||
</div>
|
||||
)}
|
||||
{type === 'bar' && (
|
||||
<BarChart data={data} height={415} onClick={onClickIssue} />
|
||||
)}
|
||||
</ChartCard>
|
||||
);
|
||||
};
|
||||
interface IssueLegendProps {
|
||||
data?: { name: string; value: number; color: string }[];
|
||||
}
|
||||
|
||||
const IssueLegend: React.FC<IssueLegendProps> = ({ data }) => {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="table-head !text-large-strong text-neutral-primary h-auto">
|
||||
Status
|
||||
</th>
|
||||
<th className="table-head !text-large-strong text-neutral-primary h-auto">
|
||||
Issue Count
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data?.map((item, index) => (
|
||||
<tr key={`item-${index}`}>
|
||||
<td className="!text-large-normal text-neutral-primary table-cell py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="h-2 w-2 flex-shrink-0 rounded-full"
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
<span>{item.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="!text-large-normal text-neutral-primary table-cell py-2">
|
||||
{item.value}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IssueBarChart;
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import dayjs from 'dayjs';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useThrottle } from 'react-use';
|
||||
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxGroup,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
ComboboxTrigger,
|
||||
Icon,
|
||||
toast,
|
||||
} from '@ufb/react';
|
||||
|
||||
import {
|
||||
ChartCard,
|
||||
client,
|
||||
commandFilter,
|
||||
getDayCount,
|
||||
InfiniteScrollArea,
|
||||
Legend,
|
||||
LineChart,
|
||||
useOAIQuery,
|
||||
} from '@/shared';
|
||||
import type { Issue } from '@/entities/issue';
|
||||
import { IssueBadge, useIssueSearchInfinite } from '@/entities/issue';
|
||||
|
||||
import { useLineChartData } from '../lib';
|
||||
|
||||
interface IProps {
|
||||
projectId: number;
|
||||
from: Date;
|
||||
to: Date;
|
||||
}
|
||||
|
||||
const IssueFeedbackLineChart: React.FC<IProps> = ({ from, projectId, to }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [searchName, setSearchName] = useState('');
|
||||
const throttledSearchName = useThrottle(searchName, 1000);
|
||||
const [currentIssues, setCurrentIssues] = useState<Issue[]>([]);
|
||||
|
||||
const { data } = useOAIQuery({
|
||||
path: '/api/admin/statistics/feedback-issue',
|
||||
variables: {
|
||||
startDate: dayjs(from).startOf('day').format('YYYY-MM-DD'),
|
||||
endDate: dayjs(to).endOf('day').format('YYYY-MM-DD'),
|
||||
issueIds: currentIssues.map((issue) => issue.id).join(','),
|
||||
interval: getDayCount(from, to) > 50 ? 'week' : 'day',
|
||||
},
|
||||
queryOptions: {
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchInterval: false,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
void client
|
||||
.post({
|
||||
path: '/api/admin/projects/{projectId}/issues/search',
|
||||
body: { sort: { feedbackCount: 'DESC' }, limit: 5 },
|
||||
pathParams: { projectId },
|
||||
})
|
||||
.then(({ data }) => setCurrentIssues(data?.items ?? []));
|
||||
}, [projectId]);
|
||||
|
||||
const { chartData, dataKeys } = useLineChartData(
|
||||
from,
|
||||
to,
|
||||
currentIssues,
|
||||
data?.issues.map((v) => ({
|
||||
id: v.id,
|
||||
statistics: v.statistics.map(({ startDate, endDate, feedbackCount }) => ({
|
||||
startDate,
|
||||
endDate,
|
||||
count: feedbackCount,
|
||||
})),
|
||||
})),
|
||||
);
|
||||
|
||||
const {
|
||||
data: allIssueData,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useIssueSearchInfinite(Number(projectId), {
|
||||
limit: 10,
|
||||
queries: [
|
||||
{ key: 'name', value: throttledSearchName, condition: 'CONTAINS' },
|
||||
],
|
||||
sort: { name: 'ASC' },
|
||||
});
|
||||
|
||||
const allIssues = useMemo(() => {
|
||||
return allIssueData.pages
|
||||
.map((v) => v?.items)
|
||||
.filter((v) => !!v)
|
||||
.flat();
|
||||
}, [allIssueData]);
|
||||
|
||||
const handleIssueCheck = (issue: Issue) => {
|
||||
if (currentIssues.length === 5) {
|
||||
toast.error(t('popover.select-issue.max-issue-count'));
|
||||
return;
|
||||
}
|
||||
setCurrentIssues((prev) => [...prev, issue]);
|
||||
setSearchName('');
|
||||
};
|
||||
const handleIssueUncheck = (issue: Issue) => {
|
||||
setCurrentIssues((prev) => prev.filter((v) => v.id !== issue.id));
|
||||
};
|
||||
|
||||
return (
|
||||
<ChartCard
|
||||
title={t('chart.issue-comparison.title')}
|
||||
description={`${t('chart.issue-comparison.description')} (${dayjs(
|
||||
from,
|
||||
).format('YYYY/MM/DD')} - ${dayjs(to).format('YYYY/MM/DD')})`}
|
||||
extra={
|
||||
<div className="flex gap-3">
|
||||
<Legend dataKeys={dataKeys} />
|
||||
<Combobox>
|
||||
<ComboboxTrigger>
|
||||
<Icon name="RiFilter3Line" />
|
||||
Filter
|
||||
</ComboboxTrigger>
|
||||
<ComboboxContent options={{ filter: commandFilter }}>
|
||||
<ComboboxInput
|
||||
onValueChange={(value) => setSearchName(value)}
|
||||
value={searchName}
|
||||
/>
|
||||
<ComboboxList maxHeight="200px">
|
||||
<ComboboxEmpty>No results found.</ComboboxEmpty>
|
||||
<ComboboxGroup
|
||||
heading={
|
||||
<span className="text-neutral-tertiary text-base-normal">
|
||||
Selected Issue
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{currentIssues.map((issue) => (
|
||||
<ComboboxItem
|
||||
key={issue.id}
|
||||
onSelect={() => handleIssueUncheck(issue)}
|
||||
>
|
||||
<IssueBadge name={issue.name} status={issue.status} />
|
||||
</ComboboxItem>
|
||||
))}
|
||||
</ComboboxGroup>
|
||||
{allIssues.length > 0 && (
|
||||
<ComboboxGroup
|
||||
heading={
|
||||
<span className="text-neutral-tertiary text-base-normal">
|
||||
Issue List
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{allIssues
|
||||
.filter(
|
||||
(v) =>
|
||||
!currentIssues.some((issue) => issue.id === v.id),
|
||||
)
|
||||
.map((issue) => (
|
||||
<ComboboxItem
|
||||
key={issue.id}
|
||||
value={issue.name}
|
||||
onSelect={() => handleIssueCheck(issue)}
|
||||
>
|
||||
<IssueBadge name={issue.name} status={issue.status} />
|
||||
</ComboboxItem>
|
||||
))}
|
||||
<InfiniteScrollArea
|
||||
fetchNextPage={fetchNextPage}
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
/>
|
||||
</ComboboxGroup>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LineChart height={400} dataKeys={dataKeys} data={chartData} />
|
||||
</ChartCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default IssueFeedbackLineChart;
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import dayjs from 'dayjs';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { ChartCard, getDayCount, LineChart, useOAIQuery } from '@/shared';
|
||||
|
||||
import { useLineChartData } from '../lib';
|
||||
|
||||
interface IProps {
|
||||
projectId: number;
|
||||
from: Date;
|
||||
to: Date;
|
||||
}
|
||||
|
||||
const IssueLineChart: React.FC<IProps> = ({ from, projectId, to }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data } = useOAIQuery({
|
||||
path: '/api/admin/statistics/issue/count-by-date',
|
||||
variables: {
|
||||
projectId,
|
||||
startDate: dayjs(from).startOf('day').format('YYYY-MM-DD'),
|
||||
endDate: dayjs(to).endOf('day').format('YYYY-MM-DD'),
|
||||
interval: getDayCount(from, to) > 50 ? 'week' : 'day',
|
||||
},
|
||||
queryOptions: {
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchInterval: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { chartData, dataKeys } = useLineChartData(
|
||||
from,
|
||||
to,
|
||||
[{ id: 1, name: '' }],
|
||||
[{ id: 1, statistics: data?.statistics ?? [] }],
|
||||
);
|
||||
|
||||
return (
|
||||
<ChartCard
|
||||
title={t('chart.issue-trend.title')}
|
||||
description={`${t('chart.issue-trend.description')} (${dayjs(from).format(
|
||||
'YYYY/MM/DD',
|
||||
)} - ${dayjs(to).format('YYYY/MM/DD')})`}
|
||||
>
|
||||
<LineChart height={400} data={chartData} dataKeys={dataKeys} />
|
||||
</ChartCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default IssueLineChart;
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import {
|
||||
createColumnHelper,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
ComboboxList,
|
||||
ComboboxSelectItem,
|
||||
ComboboxTrigger,
|
||||
Icon,
|
||||
} from '@ufb/react';
|
||||
|
||||
import { ISSUES, Path } from '@/shared';
|
||||
import { DashboardTable } from '@/shared/ui';
|
||||
import type { IssueStatus } from '@/entities/issue';
|
||||
import { IssueBadge, useIssueSearch } from '@/entities/issue';
|
||||
|
||||
interface IssueTableData {
|
||||
id: number;
|
||||
no: number;
|
||||
status: IssueStatus;
|
||||
name: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
const columnHelper = createColumnHelper<IssueTableData>();
|
||||
const columns = [
|
||||
columnHelper.accessor('no', {
|
||||
header: 'No',
|
||||
enableSorting: false,
|
||||
size: 30,
|
||||
}),
|
||||
columnHelper.accessor('name', {
|
||||
header: 'Issue',
|
||||
enableSorting: false,
|
||||
cell({ getValue, row }) {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<>
|
||||
<span>{getValue()}</span>
|
||||
<Link
|
||||
href={{
|
||||
pathname: Path.ISSUE,
|
||||
query: {
|
||||
projectId: router.query.projectId,
|
||||
queries: JSON.stringify([
|
||||
{ key: 'name', value: row.original.name, condition: 'IS' },
|
||||
]),
|
||||
},
|
||||
}}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="ml-1"
|
||||
>
|
||||
<Icon
|
||||
name="RiExternalLinkFill"
|
||||
size={16}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
</Link>
|
||||
</>
|
||||
);
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor('status', {
|
||||
header: 'Status',
|
||||
enableSorting: false,
|
||||
cell({ getValue }) {
|
||||
return <IssueBadge status={getValue()} />;
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor('count', {
|
||||
header: 'Feedback Count',
|
||||
cell: ({ getValue }) => <p>{getValue().toLocaleString()}</p>,
|
||||
enableSorting: true,
|
||||
}),
|
||||
];
|
||||
interface IProps {
|
||||
projectId: number;
|
||||
from: Date;
|
||||
to: Date;
|
||||
}
|
||||
const limitOptions = [
|
||||
{ label: 'Top 5', value: '5' },
|
||||
{ label: 'Top 10', value: '10' },
|
||||
{ label: 'Top 15', value: '15' },
|
||||
{ label: 'Top 20', value: '20' },
|
||||
];
|
||||
|
||||
const IssueRank: React.FC<IProps> = ({ projectId }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const issues = useMemo(() => ISSUES(t), [t]);
|
||||
const [limit, setLimit] = useState(Number(limitOptions[0]?.value ?? 0));
|
||||
|
||||
const [currentIssueStatusList, setCurrentIssueStatusList] = useState(issues);
|
||||
|
||||
const { data } = useIssueSearch(projectId, {
|
||||
sort: { feedbackCount: 'DESC' },
|
||||
limit,
|
||||
queries: currentIssueStatusList.map((v) => ({
|
||||
key: 'status',
|
||||
value: v.key,
|
||||
condition: 'IS',
|
||||
})),
|
||||
operator: 'OR',
|
||||
});
|
||||
|
||||
const newData = useMemo(
|
||||
() =>
|
||||
data?.items.map((item, i) => ({
|
||||
id: item.id,
|
||||
no: i + 1,
|
||||
count: item.feedbackCount,
|
||||
name: item.name,
|
||||
status: item.status,
|
||||
})) ?? [],
|
||||
[data, t],
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
columns,
|
||||
data: newData,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
});
|
||||
|
||||
return (
|
||||
<DashboardTable
|
||||
title={t('chart.issue-rank.title')}
|
||||
description={t('chart.issue-rank.description')}
|
||||
table={table}
|
||||
selectData={{
|
||||
options: limitOptions,
|
||||
value: String(limit),
|
||||
onChange: (v) => setLimit(Number(v)),
|
||||
}}
|
||||
filterContent={
|
||||
<Combobox>
|
||||
<ComboboxTrigger>
|
||||
<Icon name="RiFilter3Line" />
|
||||
Filter
|
||||
</ComboboxTrigger>
|
||||
<ComboboxContent>
|
||||
<ComboboxList>
|
||||
{issues.map((issue) => (
|
||||
<ComboboxSelectItem
|
||||
key={issue.key}
|
||||
checked={currentIssueStatusList.some(
|
||||
({ key }) => key === issue.key,
|
||||
)}
|
||||
onSelect={() => {
|
||||
const isChecked = currentIssueStatusList.some(
|
||||
({ key }) => key === issue.key,
|
||||
);
|
||||
setCurrentIssueStatusList((prev) =>
|
||||
isChecked ?
|
||||
prev.filter((v) => v.key !== issue.key)
|
||||
: [...prev, issue],
|
||||
);
|
||||
}}
|
||||
>
|
||||
{issue.name}
|
||||
</ComboboxSelectItem>
|
||||
))}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default IssueRank;
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useMemo } from 'react';
|
||||
import dayjs from 'dayjs';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { DashboardCard, useOAIQuery } from '@/shared';
|
||||
|
||||
interface IProps {
|
||||
projectId: number;
|
||||
}
|
||||
|
||||
const SevenDaysFeedbackCard: React.FC<IProps> = ({ projectId }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: currentData } = useOAIQuery({
|
||||
path: '/api/admin/statistics/feedback/count',
|
||||
variables: {
|
||||
from: dayjs().subtract(7, 'day').startOf('day').toISOString(),
|
||||
to: dayjs().subtract(1, 'day').endOf('day').toISOString(),
|
||||
projectId,
|
||||
},
|
||||
queryOptions: {
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchInterval: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { data: previousData } = useOAIQuery({
|
||||
path: '/api/admin/statistics/feedback/count',
|
||||
variables: {
|
||||
from: dayjs().subtract(14, 'day').startOf('day').toISOString(),
|
||||
to: dayjs().subtract(8, 'day').endOf('day').toISOString(),
|
||||
projectId,
|
||||
},
|
||||
queryOptions: {
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchInterval: false,
|
||||
},
|
||||
});
|
||||
const percentage = useMemo(() => {
|
||||
if (!currentData || !previousData) return 0;
|
||||
return (
|
||||
((currentData.count - previousData.count) / previousData.count) * 100
|
||||
);
|
||||
}, [currentData, previousData]);
|
||||
|
||||
return (
|
||||
<DashboardCard
|
||||
type="feedback"
|
||||
data={currentData?.count ?? 0}
|
||||
title={t('dashboard-card.n-days-feedback.title', { n: 7 })}
|
||||
description={t('dashboard-card.n-days-feedback.description', {
|
||||
n: 7,
|
||||
targetDate: `${dayjs()
|
||||
.subtract(7, 'day')
|
||||
.format('YYYY/MM/DD')} - ${dayjs()
|
||||
.subtract(1, 'day')
|
||||
.format('YYYY/MM/DD')}`,
|
||||
compareDate: `${dayjs()
|
||||
.subtract(14, 'day')
|
||||
.format('YYYY/MM/DD')} - ${dayjs()
|
||||
.subtract(8, 'day')
|
||||
.format('YYYY/MM/DD')}`,
|
||||
})}
|
||||
percentage={percentage}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default SevenDaysFeedbackCard;
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useMemo } from 'react';
|
||||
import dayjs from 'dayjs';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { DashboardCard, useOAIQuery } from '@/shared';
|
||||
|
||||
interface IProps {
|
||||
projectId: number;
|
||||
}
|
||||
|
||||
const SevenDaysIssueCard: React.FC<IProps> = ({ projectId }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: currentData } = useOAIQuery({
|
||||
path: '/api/admin/statistics/issue/count',
|
||||
variables: {
|
||||
from: dayjs().subtract(7, 'day').startOf('day').toISOString(),
|
||||
to: dayjs().subtract(1, 'day').endOf('day').toISOString(),
|
||||
projectId,
|
||||
},
|
||||
queryOptions: {
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchInterval: false,
|
||||
},
|
||||
});
|
||||
const { data: previousData } = useOAIQuery({
|
||||
path: '/api/admin/statistics/issue/count',
|
||||
variables: {
|
||||
from: dayjs().subtract(14, 'day').startOf('day').toISOString(),
|
||||
to: dayjs().subtract(8, 'day').endOf('day').toISOString(),
|
||||
projectId,
|
||||
},
|
||||
queryOptions: {
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchInterval: false,
|
||||
},
|
||||
});
|
||||
const percentage = useMemo(() => {
|
||||
if (!currentData || !previousData) return 0;
|
||||
return (
|
||||
((currentData.count - previousData.count) / previousData.count) * 100
|
||||
);
|
||||
}, [currentData, previousData]);
|
||||
|
||||
return (
|
||||
<DashboardCard
|
||||
type="issue"
|
||||
data={currentData?.count ?? 0}
|
||||
title={t('dashboard-card.n-days-issue.title', { n: 7 })}
|
||||
description={t('dashboard-card.n-days-issue.description', {
|
||||
n: 7,
|
||||
targetDate: `${dayjs()
|
||||
.subtract(7, 'day')
|
||||
.format('YYYY/MM/DD')} - ${dayjs()
|
||||
.subtract(1, 'day')
|
||||
.format('YYYY/MM/DD')}`,
|
||||
compareDate: `${dayjs()
|
||||
.subtract(14, 'day')
|
||||
.format('YYYY/MM/DD')} - ${dayjs()
|
||||
.subtract(8, 'day')
|
||||
.format('YYYY/MM/DD')}`,
|
||||
})}
|
||||
percentage={percentage}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default SevenDaysIssueCard;
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useMemo } from 'react';
|
||||
import dayjs from 'dayjs';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { DashboardCard, useOAIQuery } from '@/shared';
|
||||
|
||||
interface IProps {
|
||||
projectId: number;
|
||||
}
|
||||
|
||||
const ThirtyDaysFeedbackCard: React.FC<IProps> = ({ projectId }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: currentData } = useOAIQuery({
|
||||
path: '/api/admin/statistics/feedback/count',
|
||||
variables: {
|
||||
from: dayjs().subtract(30, 'day').startOf('day').toISOString(),
|
||||
to: dayjs().subtract(1, 'day').endOf('day').toISOString(),
|
||||
projectId,
|
||||
},
|
||||
queryOptions: {
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchInterval: false,
|
||||
},
|
||||
});
|
||||
const { data: previousData } = useOAIQuery({
|
||||
path: '/api/admin/statistics/feedback/count',
|
||||
variables: {
|
||||
from: dayjs().subtract(60, 'day').startOf('day').toISOString(),
|
||||
to: dayjs().subtract(31, 'day').endOf('day').toISOString(),
|
||||
projectId,
|
||||
},
|
||||
queryOptions: {
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchInterval: false,
|
||||
},
|
||||
});
|
||||
|
||||
const percentage = useMemo(() => {
|
||||
if (!currentData || !previousData) return 0;
|
||||
return (
|
||||
((currentData.count - previousData.count) / previousData.count) * 100
|
||||
);
|
||||
}, [currentData, previousData]);
|
||||
|
||||
return (
|
||||
<DashboardCard
|
||||
type="feedback"
|
||||
data={currentData?.count ?? 0}
|
||||
title={t('dashboard-card.n-days-feedback.title', { n: 30 })}
|
||||
description={t('dashboard-card.n-days-feedback.description', {
|
||||
n: 30,
|
||||
targetDate: `${dayjs()
|
||||
.subtract(30, 'day')
|
||||
.format('YYYY/MM/DD')} - ${dayjs()
|
||||
.subtract(1, 'day')
|
||||
.format('YYYY/MM/DD')}`,
|
||||
compareDate: `${dayjs()
|
||||
.subtract(60, 'day')
|
||||
.format('YYYY/MM/DD')} - ${dayjs()
|
||||
.subtract(31, 'day')
|
||||
.format('YYYY/MM/DD')}`,
|
||||
})}
|
||||
percentage={percentage}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThirtyDaysFeedbackCard;
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useMemo } from 'react';
|
||||
import dayjs from 'dayjs';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { DashboardCard, useOAIQuery } from '@/shared';
|
||||
|
||||
interface IProps {
|
||||
projectId: number;
|
||||
}
|
||||
|
||||
const ThirtyDaysIssueCard: React.FC<IProps> = ({ projectId }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: currentData } = useOAIQuery({
|
||||
path: '/api/admin/statistics/issue/count',
|
||||
variables: {
|
||||
from: dayjs().subtract(30, 'day').startOf('day').toISOString(),
|
||||
to: dayjs().subtract(1, 'day').endOf('day').toISOString(),
|
||||
projectId,
|
||||
},
|
||||
queryOptions: {
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchInterval: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { data: previousData } = useOAIQuery({
|
||||
path: '/api/admin/statistics/issue/count',
|
||||
variables: {
|
||||
from: dayjs().subtract(60, 'day').startOf('day').toISOString(),
|
||||
to: dayjs().subtract(31, 'day').endOf('day').toISOString(),
|
||||
projectId,
|
||||
},
|
||||
queryOptions: {
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchInterval: false,
|
||||
},
|
||||
});
|
||||
|
||||
const percentage = useMemo(() => {
|
||||
if (!currentData || !previousData) return 0;
|
||||
|
||||
return (
|
||||
((currentData.count - previousData.count) / previousData.count) * 100
|
||||
);
|
||||
}, [currentData, previousData]);
|
||||
|
||||
return (
|
||||
<DashboardCard
|
||||
type="issue"
|
||||
data={currentData?.count ?? 0}
|
||||
title={t('dashboard-card.n-days-issue.title', { n: 30 })}
|
||||
description={t('dashboard-card.n-days-issue.description', {
|
||||
n: 30,
|
||||
targetDate: `${dayjs()
|
||||
.subtract(30, 'day')
|
||||
.format('YYYY/MM/DD')} - ${dayjs()
|
||||
.subtract(1, 'day')
|
||||
.format('YYYY/MM/DD')}`,
|
||||
compareDate: `${dayjs()
|
||||
.subtract(60, 'day')
|
||||
.format('YYYY/MM/DD')} - ${dayjs()
|
||||
.subtract(31, 'day')
|
||||
.format('YYYY/MM/DD')}`,
|
||||
})}
|
||||
percentage={percentage}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThirtyDaysIssueCard;
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useMemo } from 'react';
|
||||
import dayjs from 'dayjs';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { DashboardCard, useOAIQuery } from '@/shared';
|
||||
|
||||
interface IProps {
|
||||
projectId: number;
|
||||
}
|
||||
|
||||
const TodayFeedbackCard: React.FC<IProps> = ({ projectId }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: currentData } = useOAIQuery({
|
||||
path: '/api/admin/statistics/feedback/count',
|
||||
variables: {
|
||||
from: dayjs().startOf('day').toISOString(),
|
||||
to: dayjs().endOf('day').toISOString(),
|
||||
projectId,
|
||||
},
|
||||
queryOptions: {
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchInterval: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { data: previousData } = useOAIQuery({
|
||||
path: '/api/admin/statistics/feedback/count',
|
||||
variables: {
|
||||
from: dayjs().subtract(1, 'day').startOf('day').toISOString(),
|
||||
to: dayjs().subtract(1, 'day').endOf('day').toISOString(),
|
||||
projectId,
|
||||
},
|
||||
queryOptions: {
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchInterval: false,
|
||||
},
|
||||
});
|
||||
|
||||
const percentage = useMemo(() => {
|
||||
if (!currentData || !previousData) return 0;
|
||||
return (
|
||||
((currentData.count - previousData.count) / previousData.count) * 100
|
||||
);
|
||||
}, [currentData, previousData]);
|
||||
|
||||
return (
|
||||
<DashboardCard
|
||||
type="feedback"
|
||||
data={(currentData?.count ?? 0).toLocaleString()}
|
||||
title={t('dashboard-card.today-feedback.title')}
|
||||
description={t('dashboard-card.today-feedback.description', {
|
||||
targetDate: dayjs().format('YYYY/MM/DD'),
|
||||
compareDate: dayjs().subtract(1, 'day').format('YYYY/MM/DD'),
|
||||
})}
|
||||
percentage={percentage}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default TodayFeedbackCard;
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useMemo } from 'react';
|
||||
import dayjs from 'dayjs';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { DashboardCard, useOAIQuery } from '@/shared';
|
||||
|
||||
interface IProps {
|
||||
projectId: number;
|
||||
}
|
||||
|
||||
const TodayIssueCard: React.FC<IProps> = ({ projectId }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: currentData } = useOAIQuery({
|
||||
path: '/api/admin/statistics/issue/count',
|
||||
variables: {
|
||||
from: dayjs().startOf('day').toISOString(),
|
||||
to: dayjs().endOf('day').toISOString(),
|
||||
projectId,
|
||||
},
|
||||
queryOptions: {
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchInterval: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { data: previousData } = useOAIQuery({
|
||||
path: '/api/admin/statistics/issue/count',
|
||||
variables: {
|
||||
from: dayjs().subtract(1, 'day').startOf('day').toISOString(),
|
||||
to: dayjs().subtract(1, 'day').endOf('day').toISOString(),
|
||||
projectId,
|
||||
},
|
||||
queryOptions: {
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchInterval: false,
|
||||
},
|
||||
});
|
||||
|
||||
const percentage = useMemo(() => {
|
||||
if (!currentData || !previousData) return 0;
|
||||
return (
|
||||
((currentData.count - previousData.count) / previousData.count) * 100
|
||||
);
|
||||
}, [currentData, previousData]);
|
||||
|
||||
return (
|
||||
<DashboardCard
|
||||
type="issue"
|
||||
data={currentData?.count ?? 0}
|
||||
title={t('dashboard-card.today-issue.title')}
|
||||
description={t('dashboard-card.today-issue.description', {
|
||||
targetDate: dayjs().format('YYYY/MM/DD'),
|
||||
compareDate: dayjs().subtract(1, 'day').format('YYYY/MM/DD'),
|
||||
})}
|
||||
percentage={percentage}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default TodayIssueCard;
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import dayjs from 'dayjs';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { DashboardCard, useOAIQuery } from '@/shared';
|
||||
|
||||
interface IProps {
|
||||
projectId: number;
|
||||
from: Date;
|
||||
to: Date;
|
||||
}
|
||||
|
||||
const TotalFeedbackCard: React.FC<IProps> = ({ projectId, from, to }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data } = useOAIQuery({
|
||||
path: '/api/admin/statistics/feedback/count',
|
||||
variables: {
|
||||
from: dayjs(from).startOf('day').toISOString(),
|
||||
to: dayjs(to).endOf('day').toISOString(),
|
||||
projectId,
|
||||
},
|
||||
queryOptions: {
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchInterval: false,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<DashboardCard
|
||||
type="feedback"
|
||||
data={data?.count ?? 0}
|
||||
title={t('dashboard-card.total-feedback.title')}
|
||||
description={t('dashboard-card.total-feedback.description', {
|
||||
targetDate: `${dayjs(from).format('YYYY/MM/DD')} - ${dayjs(to).format(
|
||||
'YYYY/MM/DD',
|
||||
)}`,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default TotalFeedbackCard;
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import dayjs from 'dayjs';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { DashboardCard, useOAIQuery } from '@/shared';
|
||||
|
||||
interface IProps {
|
||||
projectId: number;
|
||||
from: Date;
|
||||
to: Date;
|
||||
}
|
||||
|
||||
const TotalIssueCard: React.FC<IProps> = ({ from, to, projectId }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data } = useOAIQuery({
|
||||
path: '/api/admin/statistics/issue/count',
|
||||
variables: {
|
||||
from: dayjs(from).startOf('day').toISOString(),
|
||||
to: dayjs(to).endOf('day').toISOString(),
|
||||
projectId,
|
||||
},
|
||||
queryOptions: {
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchInterval: false,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<DashboardCard
|
||||
type="issue"
|
||||
data={data?.count ?? 0}
|
||||
title={t('dashboard-card.total-issue.title')}
|
||||
description={t('dashboard-card.total-issue.description', {
|
||||
targetDate: `${dayjs(from).format('YYYY/MM/DD')} - ${dayjs(to).format(
|
||||
'YYYY/MM/DD',
|
||||
)}`,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default TotalIssueCard;
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useMemo } from 'react';
|
||||
import dayjs from 'dayjs';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { DashboardCard, useOAIQuery } from '@/shared';
|
||||
|
||||
interface IProps {
|
||||
projectId: number;
|
||||
}
|
||||
|
||||
const YesterdayFeedbackCard: React.FC<IProps> = ({ projectId }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: currentData } = useOAIQuery({
|
||||
path: '/api/admin/statistics/feedback/count',
|
||||
variables: {
|
||||
from: dayjs().subtract(1, 'day').startOf('day').toISOString(),
|
||||
to: dayjs().subtract(1, 'day').endOf('day').toISOString(),
|
||||
projectId,
|
||||
},
|
||||
queryOptions: {
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchInterval: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { data: previousData } = useOAIQuery({
|
||||
path: '/api/admin/statistics/feedback/count',
|
||||
variables: {
|
||||
from: dayjs().subtract(2, 'day').startOf('day').toISOString(),
|
||||
to: dayjs().subtract(2, 'day').endOf('day').toISOString(),
|
||||
projectId,
|
||||
},
|
||||
queryOptions: {
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchInterval: false,
|
||||
},
|
||||
});
|
||||
const percentage = useMemo(() => {
|
||||
if (!currentData || !previousData) return 0;
|
||||
return (
|
||||
((currentData.count - previousData.count) / previousData.count) * 100
|
||||
);
|
||||
}, [currentData, previousData]);
|
||||
|
||||
return (
|
||||
<DashboardCard
|
||||
type="feedback"
|
||||
data={currentData?.count ?? 0}
|
||||
title={t('dashboard-card.yesterday-feedback.title')}
|
||||
description={t('dashboard-card.yesterday-feedback.description', {
|
||||
targetDate: dayjs().subtract(1, 'day').format('YYYY/MM/DD'),
|
||||
compareDate: dayjs().subtract(2, 'day').format('YYYY/MM/DD'),
|
||||
})}
|
||||
percentage={percentage}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default YesterdayFeedbackCard;
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useMemo } from 'react';
|
||||
import dayjs from 'dayjs';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { DashboardCard, useOAIQuery } from '@/shared';
|
||||
|
||||
interface IProps {
|
||||
projectId: number;
|
||||
}
|
||||
|
||||
const YesterdayIssueCard: React.FC<IProps> = ({ projectId }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: currentData } = useOAIQuery({
|
||||
path: '/api/admin/statistics/issue/count',
|
||||
variables: {
|
||||
from: dayjs().subtract(1, 'day').startOf('day').toISOString(),
|
||||
to: dayjs().subtract(1, 'day').endOf('day').toISOString(),
|
||||
projectId,
|
||||
},
|
||||
queryOptions: {
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchInterval: false,
|
||||
},
|
||||
});
|
||||
const { data: previousData } = useOAIQuery({
|
||||
path: '/api/admin/statistics/issue/count',
|
||||
variables: {
|
||||
from: dayjs().subtract(2, 'day').startOf('day').toISOString(),
|
||||
to: dayjs().subtract(2, 'day').endOf('day').toISOString(),
|
||||
projectId,
|
||||
},
|
||||
queryOptions: {
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchInterval: false,
|
||||
},
|
||||
});
|
||||
|
||||
const percentage = useMemo(() => {
|
||||
if (!currentData || !previousData) return 0;
|
||||
return (
|
||||
((currentData.count - previousData.count) / previousData.count) * 100
|
||||
);
|
||||
}, [currentData, previousData]);
|
||||
|
||||
return (
|
||||
<DashboardCard
|
||||
type="issue"
|
||||
data={currentData?.count ?? 0}
|
||||
title={t('dashboard-card.yesterday-issue.title')}
|
||||
description={t('dashboard-card.yesterday-issue.description', {
|
||||
targetDate: dayjs().subtract(1, 'day').format('YYYY/MM/DD'),
|
||||
compareDate: dayjs().subtract(2, 'day').format('YYYY/MM/DD'),
|
||||
})}
|
||||
percentage={percentage}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default YesterdayIssueCard;
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { ColumnDef, DisplayColumnDef } from '@tanstack/react-table';
|
||||
import { createColumnHelper } from '@tanstack/react-table';
|
||||
|
||||
import { Icon } from '@ufb/react';
|
||||
|
||||
import { cn, ExpandableText, TableCheckbox } from '@/shared';
|
||||
import { FieldFormatLabel } from '@/entities/field';
|
||||
import type { FieldInfo } from '@/entities/field';
|
||||
|
||||
import { FIELD_FORMAT_ICON_MAP } from '../field/field.constant';
|
||||
import type { Issue } from '../issue';
|
||||
import type { Feedback } from './feedback.type';
|
||||
import FeedbackCell from './ui/feedback-cell';
|
||||
import IssueCell from './ui/issue-cell';
|
||||
|
||||
const columnHelper = createColumnHelper<Feedback>();
|
||||
|
||||
export const getColumns = (
|
||||
fieldData: FieldInfo[],
|
||||
isPreview?: boolean,
|
||||
): ColumnDef<Feedback>[] =>
|
||||
[
|
||||
columnHelper.display({
|
||||
id: 'feedback-checkbox',
|
||||
header: ({ table }) => (
|
||||
<TableCheckbox
|
||||
{...{
|
||||
checked: table.getIsAllRowsSelected(),
|
||||
indeterminate: table.getIsSomeRowsSelected(),
|
||||
onCheckedChange: (checked) => table.toggleAllRowsSelected(checked),
|
||||
}}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<TableCheckbox
|
||||
{...{
|
||||
checked: row.getIsSelected(),
|
||||
disabled: !row.getCanSelect(),
|
||||
indeterminate: row.getIsSomeSelected(),
|
||||
onCheckedChange: (checked) => row.toggleSelected(checked),
|
||||
}}
|
||||
/>
|
||||
),
|
||||
size: 50,
|
||||
enableResizing: false,
|
||||
}),
|
||||
].concat(
|
||||
fieldData.map((field) =>
|
||||
field.key === 'id' ?
|
||||
columnHelper.accessor('id', {
|
||||
id: 'id',
|
||||
size: 100,
|
||||
minSize: 100,
|
||||
header: () => (
|
||||
<div className="flex items-center gap-1">
|
||||
<Icon name={FIELD_FORMAT_ICON_MAP.number} size={16} />
|
||||
{fieldData.find((v) => v.key === 'id')?.name}
|
||||
</div>
|
||||
),
|
||||
cell: (info) => (
|
||||
<ExpandableText isExpanded={info.row.getIsExpanded()}>
|
||||
{info.getValue() as number}
|
||||
</ExpandableText>
|
||||
),
|
||||
enableSorting: false,
|
||||
})
|
||||
: field.key === 'issues' ?
|
||||
columnHelper.accessor('issues', {
|
||||
id: 'issues',
|
||||
size: 150,
|
||||
minSize: 150,
|
||||
header: () => <FieldFormatLabel format="multiSelect" name="Issue" />,
|
||||
cell: ({ getValue, row }) => (
|
||||
<div className={cn({ 'overflow-hidden': !row.getIsExpanded() })}>
|
||||
<div className={cn('flex', { 'w-max': !row.getIsExpanded() })}>
|
||||
<IssueCell
|
||||
issues={getValue() as Issue[]}
|
||||
feedbackId={Number(row.id)}
|
||||
isPreview={isPreview}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
})
|
||||
: columnHelper.accessor(field.key, {
|
||||
id: field.key,
|
||||
size: field.format === 'text' ? 200 : 150,
|
||||
minSize: 75,
|
||||
header: () => (
|
||||
<FieldFormatLabel format={field.format} name={field.name} />
|
||||
),
|
||||
cell: (info) => (
|
||||
<FeedbackCell
|
||||
field={field}
|
||||
isExpanded={info.row.getIsExpanded()}
|
||||
value={info.getValue()}
|
||||
feedbackId={Number(info.row.id)}
|
||||
/>
|
||||
),
|
||||
meta: {
|
||||
truncate: !(
|
||||
field.format === 'select' ||
|
||||
field.format === 'multiSelect' ||
|
||||
field.format === 'images'
|
||||
),
|
||||
},
|
||||
enableSorting:
|
||||
field.format === 'date' &&
|
||||
(field.key === 'createdAt' || field.key === 'updatedAt'),
|
||||
}),
|
||||
) as DisplayColumnDef<Feedback>[],
|
||||
);
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
export type Feedback = Record<string, unknown>;
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export * from './lib';
|
||||
export * from './ui';
|
||||
export * from './feedback.type';
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export { default as useFeedbackSearch } from './use-feedback-search';
|
||||
export { default as usePreviewFeedback } from './use-preview-feedback';
|
||||
export { default as useFeedbackQueryConverter } from './use-feedback-query-converter';
|
||||
export { default as useAIFIeldFeedbackCellLoading } from './use-ai-field-feedback-cell-loading';
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { create } from 'zustand';
|
||||
|
||||
const useAIFIeldFeedbackCellLoading = create<{
|
||||
loadingFeedbackIds: Set<number>;
|
||||
setLoadingFeedbackIds: (ids: number[]) => void;
|
||||
}>((set) => ({
|
||||
loadingFeedbackIds: new Set(),
|
||||
setLoadingFeedbackIds: (ids) => set({ loadingFeedbackIds: new Set(ids) }),
|
||||
}));
|
||||
export default useAIFIeldFeedbackCellLoading;
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { UseMutationOptions } from '@tanstack/react-query';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import type { IFetchError, OAIPathParameters, OAIRequestBody } from '@/shared';
|
||||
import { client } from '@/shared';
|
||||
|
||||
type IPath = OAIPathParameters<
|
||||
'/api/admin/projects/{projectId}/channels/{channelId}/feedbacks/export',
|
||||
'post'
|
||||
>;
|
||||
type IBody = OAIRequestBody<
|
||||
'/api/admin/projects/{projectId}/channels/{channelId}/feedbacks/export',
|
||||
'post'
|
||||
>;
|
||||
|
||||
interface IInput {
|
||||
params: IPath;
|
||||
options?: Omit<
|
||||
UseMutationOptions<void, IFetchError, IBody, unknown>,
|
||||
'mutationKey' | 'mutationFn'
|
||||
>;
|
||||
}
|
||||
const useFeedbackDownload = ({ params, options }: IInput) => {
|
||||
return useMutation({
|
||||
mutationFn: async (body: IBody) => {
|
||||
const { data, headers } = await client.post({
|
||||
path: '/api/admin/projects/{projectId}/channels/{channelId}/feedbacks/export',
|
||||
pathParams: params,
|
||||
body,
|
||||
config: { responseType: 'arraybuffer' },
|
||||
});
|
||||
|
||||
const filename = ((headers['content-disposition'] ?? '') as string).split(
|
||||
'filename=',
|
||||
)[1];
|
||||
|
||||
if (!filename || typeof data !== 'object') return;
|
||||
|
||||
const decodedFilename = decodeURI(filename.slice(1, filename.length - 1));
|
||||
|
||||
const url = window.URL.createObjectURL(new Blob([data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', decodedFilename);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export default useFeedbackDownload;
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import dayjs from 'dayjs';
|
||||
import { createParser, parseAsStringLiteral, useQueryState } from 'nuqs';
|
||||
|
||||
import type {
|
||||
DateRangeType,
|
||||
SearchQuery,
|
||||
TableFilter,
|
||||
TableFilterField,
|
||||
TableFilterFieldFotmat,
|
||||
TableFilterOperator,
|
||||
} from '@/shared';
|
||||
import { client, TableFilterOperators } from '@/shared';
|
||||
|
||||
const toQuery = (
|
||||
projectId: number,
|
||||
): Record<TableFilterFieldFotmat, (valeu: unknown) => unknown> => ({
|
||||
number: (value) => Number(value),
|
||||
issue: async (value) => {
|
||||
if (typeof value === 'string') {
|
||||
const { data } = await client.post({
|
||||
path: '/api/admin/projects/{projectId}/issues/search',
|
||||
pathParams: { projectId },
|
||||
body: {
|
||||
queries: [{ key: 'name', value, condition: 'IS' }] as SearchQuery[],
|
||||
},
|
||||
});
|
||||
const result = data?.items.find((v) => v.name === value);
|
||||
return result ? [result.id] : [];
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return await Promise.all(
|
||||
value.map(async (v: string) => {
|
||||
const issueId = parseInt(v);
|
||||
if (!isNaN(issueId)) return issueId;
|
||||
|
||||
const { data } = await client.post({
|
||||
path: '/api/admin/projects/{projectId}/issues/search',
|
||||
pathParams: { projectId },
|
||||
body: {
|
||||
queries: [
|
||||
{ key: 'name', value: v, condition: 'IS' },
|
||||
] as SearchQuery[],
|
||||
},
|
||||
});
|
||||
return data?.items.find((vv) => vv.name === v)?.id;
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
string: (value) => value,
|
||||
select: (value) => value,
|
||||
date: (value) => value,
|
||||
ticket: (value) => value,
|
||||
multiSelect: (value) => value,
|
||||
});
|
||||
|
||||
const DEFAULT_DATE_RANGE = (
|
||||
feedbackSearchMaxDays: number,
|
||||
): SearchQuery | null =>
|
||||
feedbackSearchMaxDays === -1 ? null : (
|
||||
{
|
||||
key: 'createdAt',
|
||||
value: {
|
||||
gte: dayjs()
|
||||
.subtract(feedbackSearchMaxDays - 1, 'day')
|
||||
.startOf('day')
|
||||
.toDate(),
|
||||
lt: dayjs().endOf('day').toDate(),
|
||||
},
|
||||
condition: 'BETWEEN',
|
||||
}
|
||||
);
|
||||
|
||||
const useFeedbackQueryConverter = (input: {
|
||||
projectId: number;
|
||||
filterFields: TableFilterField[];
|
||||
feedbackSearchMaxDays: number;
|
||||
}) => {
|
||||
const { projectId, filterFields, feedbackSearchMaxDays } = input;
|
||||
const defaultDateRange = DEFAULT_DATE_RANGE(feedbackSearchMaxDays);
|
||||
|
||||
const [operator, setOperator] = useQueryState<TableFilterOperator>(
|
||||
'operator',
|
||||
parseAsStringLiteral(TableFilterOperators).withDefault('AND'),
|
||||
);
|
||||
|
||||
const [queries, setQueries] = useQueryState<SearchQuery[]>(
|
||||
'queries',
|
||||
createParser({
|
||||
parse: (value) => JSON.parse(value) as SearchQuery[],
|
||||
serialize: (value) => JSON.stringify(value),
|
||||
}).withDefault([]),
|
||||
);
|
||||
|
||||
const [defaultQueries, setDefaultQueries] = useQueryState<SearchQuery[]>(
|
||||
'defaultQueries',
|
||||
createParser({
|
||||
parse: (value) => JSON.parse(value) as SearchQuery[],
|
||||
serialize: (value) => JSON.stringify(value),
|
||||
}).withDefault(defaultDateRange ? [defaultDateRange] : []),
|
||||
);
|
||||
|
||||
const dateRange = useMemo(() => {
|
||||
const dateQuery = defaultQueries.find((v) => v.key === 'createdAt');
|
||||
|
||||
const createdAt = dateQuery?.value as
|
||||
| { gte: string; lt: string }
|
||||
| undefined;
|
||||
|
||||
return createdAt ?
|
||||
{
|
||||
startDate: dayjs(createdAt.gte).toDate(),
|
||||
endDate: dayjs(createdAt.lt).toDate(),
|
||||
}
|
||||
: null;
|
||||
}, [defaultQueries]);
|
||||
|
||||
const onChangeDateRange = useCallback(async (value: DateRangeType) => {
|
||||
if (!value) {
|
||||
await setDefaultQueries([]);
|
||||
return;
|
||||
}
|
||||
await setDefaultQueries([
|
||||
{
|
||||
key: 'createdAt',
|
||||
value: {
|
||||
gte: dayjs(value.startDate).toISOString(),
|
||||
lt: dayjs(value.endDate).toISOString(),
|
||||
},
|
||||
condition: 'BETWEEN',
|
||||
},
|
||||
]);
|
||||
}, []);
|
||||
|
||||
const tableFilters = useMemo(() => {
|
||||
return queries
|
||||
.map(({ key, value, condition }) => {
|
||||
const field = filterFields.find((v) => v.key === key);
|
||||
|
||||
if (!field) return null;
|
||||
const { format, name } = field;
|
||||
return { key, name, value, format, condition };
|
||||
})
|
||||
.filter((v) => !!v) as TableFilter[];
|
||||
}, [queries, filterFields]);
|
||||
|
||||
const onChangeTableFilters = useCallback(
|
||||
async (tableFilters: TableFilter[], operator: TableFilterOperator) => {
|
||||
const result = await Promise.all(
|
||||
tableFilters.map(async ({ condition, format, key, value }) => ({
|
||||
key,
|
||||
value: await toQuery(projectId)[format](value),
|
||||
condition,
|
||||
})),
|
||||
);
|
||||
|
||||
if (result.length === 0 && !dateRange) {
|
||||
await setDefaultQueries(defaultDateRange ? [defaultDateRange] : []);
|
||||
await setQueries([]);
|
||||
await setOperator('AND');
|
||||
return;
|
||||
}
|
||||
|
||||
await setOperator(operator);
|
||||
await setQueries(result);
|
||||
},
|
||||
[dateRange],
|
||||
);
|
||||
|
||||
const filteredQueries = queries.filter((query) =>
|
||||
filterFields.some((field) => field.key === query.key),
|
||||
);
|
||||
|
||||
const filteredTableFilters = tableFilters.filter((v) =>
|
||||
filterFields.some((vv) => vv.key === v.key),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const createdAtQuery = defaultQueries.find((query) => query.key === 'createdAt');
|
||||
|
||||
if (feedbackSearchMaxDays === -1) {
|
||||
if (createdAtQuery) {
|
||||
void setDefaultQueries([]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!createdAtQuery && defaultDateRange) {
|
||||
void setDefaultQueries([defaultDateRange]);
|
||||
return;
|
||||
}
|
||||
|
||||
const diff = dayjs(dateRange?.endDate).diff(
|
||||
dayjs(dateRange?.startDate),
|
||||
'day',
|
||||
);
|
||||
if (diff >= feedbackSearchMaxDays) {
|
||||
void onChangeDateRange({
|
||||
startDate: dayjs()
|
||||
.subtract(feedbackSearchMaxDays - 1, 'day')
|
||||
.toDate(),
|
||||
endDate: dayjs().toDate(),
|
||||
});
|
||||
}
|
||||
}, [defaultDateRange, defaultQueries, feedbackSearchMaxDays]);
|
||||
|
||||
return {
|
||||
queries: filteredQueries,
|
||||
defaultQueries,
|
||||
tableFilters: filteredTableFilters,
|
||||
operator,
|
||||
dateRange,
|
||||
updateTableFilters: onChangeTableFilters,
|
||||
updateDateRange: onChangeDateRange,
|
||||
};
|
||||
};
|
||||
|
||||
export default useFeedbackQueryConverter;
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { UseQueryOptions } from '@tanstack/react-query';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import type {
|
||||
IFetchError,
|
||||
OAIMutationResponse,
|
||||
OAIRequestBody,
|
||||
SearchQuery,
|
||||
} from '@/shared';
|
||||
import { client } from '@/shared';
|
||||
|
||||
type TData = OAIMutationResponse<
|
||||
'/api/admin/projects/{projectId}/channels/{channelId}/feedbacks/search',
|
||||
'post'
|
||||
>;
|
||||
|
||||
interface IBody
|
||||
extends Omit<
|
||||
OAIRequestBody<
|
||||
'/api/admin/projects/{projectId}/channels/{channelId}/feedbacks/search',
|
||||
'post'
|
||||
>,
|
||||
'queries'
|
||||
> {
|
||||
queries: SearchQuery[];
|
||||
}
|
||||
|
||||
const useFeedbackSearch = (
|
||||
projectId: number,
|
||||
channelId: number,
|
||||
body: IBody,
|
||||
options?: Omit<UseQueryOptions<TData, IFetchError>, 'queryKey' | 'queryFn'>,
|
||||
) => {
|
||||
return useQuery<TData, IFetchError>({
|
||||
queryKey: [
|
||||
'/api/admin/projects/{projectId}/channels/{channelId}/feedbacks/search',
|
||||
projectId,
|
||||
channelId,
|
||||
body,
|
||||
],
|
||||
queryFn: async () => {
|
||||
const { data: result } = await client.post({
|
||||
path: '/api/admin/projects/{projectId}/channels/{channelId}/feedbacks/search',
|
||||
pathParams: { projectId, channelId },
|
||||
body,
|
||||
});
|
||||
return result;
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export default useFeedbackSearch;
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useMemo } from 'react';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import dayjs from 'dayjs';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { ISSUES } from '@/shared';
|
||||
import type { Category } from '@/entities/category';
|
||||
import type { FieldInfo } from '@/entities/field';
|
||||
import type { Issue } from '@/entities/issue';
|
||||
|
||||
import type { Feedback } from '../feedback.type';
|
||||
|
||||
const usePreviewFeedback = (fields: FieldInfo[]) => {
|
||||
const { t } = useTranslation();
|
||||
const feedbacks = useMemo(() => {
|
||||
const feedbacks: Feedback[] = [];
|
||||
const categories: Category[] = new Array(5).fill(0).map((_, i) => ({
|
||||
id: i,
|
||||
name: faker.word.sample(),
|
||||
}));
|
||||
const issues: Issue[] = faker.helpers
|
||||
.uniqueArray(() => faker.word.sample(), 10)
|
||||
.map((v) => ({
|
||||
id: faker.number.int(),
|
||||
createdAt: faker.date.recent().toString(),
|
||||
description: faker.lorem.sentence(),
|
||||
feedbackCount: faker.number.int(),
|
||||
updatedAt: faker.date.recent().toString(),
|
||||
name: v,
|
||||
status: faker.helpers.arrayElement(ISSUES(t).map((v) => v.key)),
|
||||
category: faker.helpers.arrayElement(categories),
|
||||
}));
|
||||
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
const fakeData: Feedback = {
|
||||
id: i,
|
||||
createdAt: dayjs().add(i, 'hour').toString(),
|
||||
updatedAt: dayjs().add(i, 'hour').toString(),
|
||||
issues: faker.helpers.arrayElements(issues, {
|
||||
min: 0,
|
||||
max: 4,
|
||||
}),
|
||||
};
|
||||
for (const field of fields) {
|
||||
if (
|
||||
field.key === 'id' ||
|
||||
field.key === 'createdAt' ||
|
||||
field.key === 'updatedAt' ||
|
||||
field.key === 'issues'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
fakeData[field.key] =
|
||||
field.format === 'date' ? faker.date.anytime()
|
||||
: field.format === 'keyword' ? faker.word.noun()
|
||||
: field.format === 'multiSelect' ?
|
||||
faker.helpers.arrayElements(
|
||||
(field.options ?? []).map((v) => v.name),
|
||||
)
|
||||
: field.format === 'select' ?
|
||||
(field.options ?? []).length > 0 ?
|
||||
faker.helpers.arrayElement(
|
||||
(field.options ?? []).map((v) => v.name),
|
||||
)
|
||||
: undefined
|
||||
: field.format === 'number' ? faker.number.int()
|
||||
: field.format === 'text' ? faker.lorem.text()
|
||||
: field.format === 'aiField' ?
|
||||
{
|
||||
status: 'success',
|
||||
message: 'The AI response results will be displayed here.',
|
||||
}
|
||||
: faker.helpers.arrayElements(
|
||||
Array.from({
|
||||
length: 15,
|
||||
}).fill('/assets/images/sample_image.png'),
|
||||
);
|
||||
}
|
||||
|
||||
feedbacks.push(fakeData);
|
||||
}
|
||||
return feedbacks;
|
||||
}, [fields]);
|
||||
return feedbacks;
|
||||
};
|
||||
|
||||
export default usePreviewFeedback;
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { Badge, Button, ComboboxGroup, ComboboxItem, Icon } from '@ufb/react';
|
||||
|
||||
import type { TableFilterCondition } from '@/shared';
|
||||
import {
|
||||
client,
|
||||
cn,
|
||||
GRADIENT_CSS,
|
||||
useOAIMutation,
|
||||
useOAIQuery,
|
||||
} from '@/shared';
|
||||
import type { Issue, IssueStatus } from '@/entities/issue';
|
||||
|
||||
interface Props {
|
||||
projectId: number;
|
||||
channelId: number;
|
||||
feedbackId: number;
|
||||
onSelect?: (issueItem: AIIssueRecommendationItem) => void;
|
||||
currentIssues: Issue[];
|
||||
}
|
||||
|
||||
type AIIssueRecommendationItem = {
|
||||
id?: number;
|
||||
name: string;
|
||||
option: 'ADDED' | 'CREATE' | 'EXISTING';
|
||||
status?: IssueStatus;
|
||||
};
|
||||
|
||||
const AiIssueComboboxGroup = ({
|
||||
projectId,
|
||||
channelId,
|
||||
feedbackId,
|
||||
onSelect,
|
||||
currentIssues,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const [issues, setIssues] = useState<AIIssueRecommendationItem[]>([]);
|
||||
const [searchedIssues, setSearchedIssues] = useState<Issue[]>([]);
|
||||
const [status, setStatus] = useState<
|
||||
'success' | 'error' | 'pending' | 'idle'
|
||||
>('idle');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
|
||||
const { data: aiIntegrations } = useOAIQuery({
|
||||
path: '/api/admin/projects/{projectId}/ai/integrations',
|
||||
variables: { projectId },
|
||||
});
|
||||
const { data: aiIssueTemplates } = useOAIQuery({
|
||||
path: '/api/admin/projects/{projectId}/ai/issueTemplates',
|
||||
variables: { projectId },
|
||||
});
|
||||
const { mutateAsync: recommendIssues, isPending } = useOAIMutation({
|
||||
method: 'post',
|
||||
path: '/api/admin/projects/{projectId}/ai/issueRecommend/{feedbackId}',
|
||||
pathParams: { projectId, feedbackId },
|
||||
queryOptions: {
|
||||
async onSuccess(data) {
|
||||
if (!data) return;
|
||||
if (data.success) {
|
||||
setStatus('success');
|
||||
await getIssueRecommendationItems(data.result);
|
||||
}
|
||||
if (!data.success) {
|
||||
setStatus('error');
|
||||
setErrorMessage(data.message);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const getIssueRecommendationItems = async (
|
||||
input: { issueName: string }[],
|
||||
) => {
|
||||
const issueNames = input.map((v) => v.issueName);
|
||||
const { data: searchedIssuesResponse } = await client.post({
|
||||
path: '/api/admin/projects/{projectId}/issues/search',
|
||||
pathParams: { projectId },
|
||||
body: {
|
||||
operator: 'OR',
|
||||
queries: issueNames.map((name) => ({
|
||||
key: 'name',
|
||||
condition: 'IS' as TableFilterCondition,
|
||||
value: name,
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
const searchedIssuesList = searchedIssuesResponse?.items ?? [];
|
||||
setSearchedIssues(searchedIssuesList);
|
||||
|
||||
const result: AIIssueRecommendationItem[] = input.map(({ issueName }) => {
|
||||
const existingIssue = currentIssues.find(
|
||||
({ name }) => name === issueName,
|
||||
);
|
||||
if (existingIssue) {
|
||||
return {
|
||||
id: existingIssue.id,
|
||||
name: issueName,
|
||||
option: 'ADDED',
|
||||
status: existingIssue.status,
|
||||
};
|
||||
}
|
||||
|
||||
const matchedIssue = searchedIssuesList.find(
|
||||
({ name }) => name === issueName,
|
||||
);
|
||||
if (matchedIssue) {
|
||||
return {
|
||||
id: matchedIssue.id,
|
||||
name: issueName,
|
||||
option: 'EXISTING',
|
||||
status: matchedIssue.status,
|
||||
};
|
||||
}
|
||||
|
||||
return { name: issueName, option: 'CREATE' };
|
||||
});
|
||||
|
||||
setIssues(result);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setIssues((prev) =>
|
||||
prev.map((issue) => {
|
||||
if (currentIssues.some(({ name }) => name === issue.name)) {
|
||||
return { ...issue, option: 'ADDED' };
|
||||
}
|
||||
|
||||
if (searchedIssues.some(({ name }) => name === issue.name)) {
|
||||
return { ...issue, option: 'EXISTING' };
|
||||
}
|
||||
|
||||
return { ...issue, option: 'CREATE' };
|
||||
}),
|
||||
);
|
||||
}, [currentIssues, searchedIssues]);
|
||||
|
||||
const aiIssue = aiIssueTemplates?.find((v) => v.channelId === channelId);
|
||||
|
||||
if (!aiIntegrations?.apiKey) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ComboboxGroup
|
||||
className="border-neutral-tertiary border-b"
|
||||
heading={
|
||||
status === 'success' && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-neutral-tertiary text-base-normal">
|
||||
AI Recommend
|
||||
</span>
|
||||
<p
|
||||
className="text-small-normal cursor-pointer"
|
||||
onClick={() => recommendIssues(undefined)}
|
||||
>
|
||||
Retry
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
>
|
||||
{status !== 'success' && (
|
||||
<div className="px-2 py-1">
|
||||
<Button
|
||||
size="small"
|
||||
className="w-full"
|
||||
onClick={() => recommendIssues(undefined)}
|
||||
style={GRADIENT_CSS.primary}
|
||||
disabled={!aiIssue?.isEnabled}
|
||||
loading={isPending}
|
||||
>
|
||||
<Icon name="RiSparklingFill" />
|
||||
Run AI
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-neutral-tertiary text-small-normal px-2 py-1 text-center">
|
||||
{!aiIssue?.isEnabled ?
|
||||
t('v2.description.ai-issue-recommendation-setting-required')
|
||||
: status === 'idle' ?
|
||||
t('v2.text.ai-issue-ready')
|
||||
: status === 'pending' ?
|
||||
t('v2.text.ai-issue-pending')
|
||||
: ''}
|
||||
</p>
|
||||
{status === 'success' &&
|
||||
issues.map((issue) => (
|
||||
<ComboboxItem
|
||||
key={issue.name}
|
||||
value={issue.name}
|
||||
onSelect={() => onSelect?.(issue)}
|
||||
className={cn('group justify-between gap-4', {
|
||||
'cursor-not-allowed opacity-50': issue.option === 'ADDED',
|
||||
})}
|
||||
disabled={issue.option === 'ADDED'}
|
||||
>
|
||||
<Badge style={GRADIENT_CSS.primary}>{issue.name}</Badge>
|
||||
{issue.option === 'ADDED' ?
|
||||
<Icon name="RiCheckLine" size={16} />
|
||||
: <span className="text-small-normal text-neutral-tertiary opacity-0 transition-opacity group-hover:opacity-100">
|
||||
{issue.option === 'CREATE' && 'Create'}
|
||||
{issue.option === 'EXISTING' && 'Add'}
|
||||
</span>
|
||||
}
|
||||
</ComboboxItem>
|
||||
))}
|
||||
{status === 'error' && (
|
||||
<div className="px-2 py-1 text-red-500">{errorMessage}</div>
|
||||
)}
|
||||
</ComboboxGroup>
|
||||
);
|
||||
};
|
||||
|
||||
export default AiIssueComboboxGroup;
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { memo } from 'react';
|
||||
import dayjs from 'dayjs';
|
||||
import Linkify from 'linkify-react';
|
||||
|
||||
import { Badge, Icon, Tag } from '@ufb/react';
|
||||
|
||||
import { DATE_TIME_FORMAT, ExpandableText, ImagePreviewButton } from '@/shared';
|
||||
import { AICell } from '@/entities/ai';
|
||||
import type { FieldInfo } from '@/entities/field';
|
||||
|
||||
import { useAIFIeldFeedbackCellLoading } from '..';
|
||||
|
||||
interface IProps {
|
||||
isExpanded: boolean;
|
||||
field: FieldInfo;
|
||||
value: unknown;
|
||||
feedbackId: number;
|
||||
}
|
||||
|
||||
const toRenderableText = (value: unknown) => {
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value);
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const FeedbackCell: React.FC<IProps> = memo((props) => {
|
||||
const { isExpanded, field, value, feedbackId } = props;
|
||||
const renderableText = toRenderableText(value);
|
||||
|
||||
return (
|
||||
<ExpandableText isExpanded={isExpanded}>
|
||||
{typeof value === 'undefined' || value === null ?
|
||||
undefined
|
||||
: <>
|
||||
{field.format === 'date' &&
|
||||
dayjs(value as string).format(DATE_TIME_FORMAT)}
|
||||
{field.format === 'multiSelect' && (
|
||||
<div className="flex gap-2">
|
||||
{(value as string[])
|
||||
.sort(
|
||||
(aKey, bKey) =>
|
||||
(field.options ?? []).findIndex(
|
||||
(option) => option.key === aKey,
|
||||
) -
|
||||
(field.options ?? []).findIndex(
|
||||
(option) => option.key === bKey,
|
||||
),
|
||||
)
|
||||
.map((key) => (
|
||||
<Badge variant="subtle" key={key}>
|
||||
{
|
||||
(field.options?.find((option) => option.key === key)
|
||||
?.name ?? value) as string
|
||||
}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{field.format === 'select' && (
|
||||
<Badge variant="subtle">
|
||||
{field.options?.find((option) => option.key === value)?.name ??
|
||||
''}
|
||||
</Badge>
|
||||
)}
|
||||
{field.format === 'images' && (
|
||||
<>
|
||||
{Array.isArray(value) && (value as string[]).length > 0 ?
|
||||
<ImagePreviewButton urls={value}>
|
||||
<Tag
|
||||
aria-label="View images"
|
||||
variant="outline"
|
||||
size="small"
|
||||
className="cursor-pointer gap-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Icon name="RiImageFill" size={12} />
|
||||
Image
|
||||
</Tag>
|
||||
</ImagePreviewButton>
|
||||
: '-'}
|
||||
</>
|
||||
)}
|
||||
{field.format === 'text' && (
|
||||
<Linkify
|
||||
options={{
|
||||
className: 'text-blue-500 underline',
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
attributes: {
|
||||
onClick: (e: React.MouseEvent) => e.stopPropagation(),
|
||||
},
|
||||
}}
|
||||
>
|
||||
{renderableText}
|
||||
</Linkify>
|
||||
)}
|
||||
{field.format === 'keyword' && renderableText}
|
||||
{field.format === 'number' && renderableText}
|
||||
{field.format === 'aiField' && (
|
||||
<FeedbackAIFieldCell
|
||||
value={
|
||||
value as
|
||||
| { status: 'loading' | 'success' | 'error'; message: string }
|
||||
| undefined
|
||||
}
|
||||
feedbackId={feedbackId}
|
||||
field={field}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
</ExpandableText>
|
||||
);
|
||||
});
|
||||
|
||||
const FeedbackAIFieldCell: React.FC<{
|
||||
value?: { status: 'loading' | 'success' | 'error'; message: string };
|
||||
feedbackId: number;
|
||||
field: FieldInfo;
|
||||
}> = memo((props) => {
|
||||
const { field, value, feedbackId } = props;
|
||||
const loadingFeedbackIds = useAIFIeldFeedbackCellLoading(
|
||||
(state) => state.loadingFeedbackIds,
|
||||
);
|
||||
|
||||
return (
|
||||
<AICell
|
||||
value={value}
|
||||
isLoading={
|
||||
loadingFeedbackIds.has(feedbackId) && field.status === 'ACTIVE'
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default FeedbackCell;
|
||||
@@ -0,0 +1,581 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useOverlay } from '@toss/use-overlay';
|
||||
import dayjs from 'dayjs';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Divider,
|
||||
Icon,
|
||||
Sheet,
|
||||
SheetBody,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
Tag,
|
||||
toast,
|
||||
} from '@ufb/react';
|
||||
|
||||
import {
|
||||
DeleteDialog,
|
||||
SUPPORT_TEST_ADMIN_USER_EMAIL,
|
||||
SUPPORT_TEST_TENANT_ID,
|
||||
getSupportWorkspaceCodeByProjectChannel,
|
||||
isObjectEqual,
|
||||
SheetDetailTable,
|
||||
usePermissions,
|
||||
} from '@/shared';
|
||||
import type { SheetDetailTableRow } from '@/shared/ui/sheet-detail-table.ui';
|
||||
import type { FieldInfo } from '@/entities/field';
|
||||
import { DEFAULT_FIELD_KEYS } from '@/entities/field/field.constant';
|
||||
|
||||
import type { Feedback } from '../feedback.type';
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
close: () => void;
|
||||
fields: FieldInfo[];
|
||||
feedback: Feedback;
|
||||
onClickDelete?: () => Promise<unknown>;
|
||||
updateFeedback?: (feedback: Feedback) => Promise<unknown>;
|
||||
projectId: number;
|
||||
channelId: number;
|
||||
refetchFeedback?: () => Promise<unknown>;
|
||||
}
|
||||
|
||||
interface SupportTicketCommentRecord {
|
||||
comment_id: number;
|
||||
ticket_id: number;
|
||||
author_id: string;
|
||||
author_tenant_id: string;
|
||||
author_name: string;
|
||||
content: string;
|
||||
is_internal: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
edited_at: string | null;
|
||||
}
|
||||
|
||||
const FeedbackDetailSheet = (props: Props) => {
|
||||
const {
|
||||
close,
|
||||
feedback,
|
||||
fields,
|
||||
isOpen,
|
||||
onClickDelete,
|
||||
projectId,
|
||||
updateFeedback,
|
||||
channelId,
|
||||
refetchFeedback,
|
||||
} = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const perms = usePermissions();
|
||||
const [currentFeedback, setCurrentFeedback] = useState(feedback);
|
||||
const [mode, setMode] = useState<'edit' | 'view'>('view');
|
||||
const overlay = useOverlay();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [comments, setComments] = useState<SupportTicketCommentRecord[]>([]);
|
||||
const [commentDraft, setCommentDraft] = useState('');
|
||||
const [editingCommentId, setEditingCommentId] = useState<number | null>(null);
|
||||
const [editingCommentDraft, setEditingCommentDraft] = useState('');
|
||||
const [isCommentsLoading, setIsCommentsLoading] = useState(false);
|
||||
const [isCommentSaving, setIsCommentSaving] = useState(false);
|
||||
const feedbackId = Number(feedback.id);
|
||||
const workspaceCode = useMemo(
|
||||
() => getSupportWorkspaceCodeByProjectChannel(projectId, channelId),
|
||||
[channelId, projectId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentFeedback(feedback);
|
||||
}, [feedback]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !workspaceCode || !Number.isFinite(feedbackId)) {
|
||||
setComments([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchComments = async () => {
|
||||
setIsCommentsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/support/tickets/${feedbackId}/comments?workspaceCode=${workspaceCode}`,
|
||||
);
|
||||
const data = (await response.json()) as
|
||||
| SupportTicketCommentRecord[]
|
||||
| { message?: string };
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
'message' in data && data.message ? data.message : '댓글을 불러오지 못했습니다.',
|
||||
);
|
||||
}
|
||||
|
||||
setComments(data as SupportTicketCommentRecord[]);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : '댓글 조회 중 오류가 발생했습니다.',
|
||||
);
|
||||
} finally {
|
||||
setIsCommentsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void fetchComments();
|
||||
}, [feedbackId, isOpen, workspaceCode]);
|
||||
|
||||
const onClickCancel = () => {
|
||||
setMode('view');
|
||||
setCurrentFeedback(feedback);
|
||||
};
|
||||
|
||||
const onClickSubmit = async () => {
|
||||
const editedFeedback = fields
|
||||
.filter((v) => v.property === 'EDITABLE' && v.status === 'ACTIVE')
|
||||
.reduce((acc, cur) => {
|
||||
if (cur.key === 'issues') return acc;
|
||||
if (cur.format === 'date') {
|
||||
return {
|
||||
...acc,
|
||||
[cur.key]:
|
||||
currentFeedback[cur.key] ?
|
||||
dayjs(currentFeedback[cur.key] as string).toISOString()
|
||||
: null,
|
||||
};
|
||||
}
|
||||
if (cur.format === 'multiSelect') {
|
||||
return {
|
||||
...acc,
|
||||
[cur.key]: currentFeedback[cur.key] ?? [],
|
||||
};
|
||||
}
|
||||
if (cur.format === 'select') {
|
||||
return {
|
||||
...acc,
|
||||
[cur.key]: currentFeedback[cur.key] ?? null,
|
||||
};
|
||||
}
|
||||
return { ...acc, [cur.key]: currentFeedback[cur.key] };
|
||||
}, {} as Feedback);
|
||||
|
||||
try {
|
||||
await updateFeedback?.(editedFeedback);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setMode('view');
|
||||
}
|
||||
};
|
||||
|
||||
const openDeleteDialog = () => {
|
||||
overlay.open(({ close: dialogClose, isOpen }) => (
|
||||
<DeleteDialog
|
||||
close={dialogClose}
|
||||
isOpen={isOpen}
|
||||
onClickDelete={async () => {
|
||||
await onClickDelete?.();
|
||||
dialogClose();
|
||||
close();
|
||||
}}
|
||||
/>
|
||||
));
|
||||
};
|
||||
|
||||
const handleCreateComment = async () => {
|
||||
if (!workspaceCode || !Number.isFinite(feedbackId) || isCommentSaving) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextContent = commentDraft.trim();
|
||||
|
||||
if (!nextContent) {
|
||||
toast.error('댓글 내용을 입력해 주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCommentSaving(true);
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/support/tickets/${feedbackId}/comments?workspaceCode=${workspaceCode}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
requester_id: SUPPORT_TEST_ADMIN_USER_EMAIL,
|
||||
requester_tenant_id: SUPPORT_TEST_TENANT_ID,
|
||||
author_name: 'Admin User',
|
||||
content: nextContent,
|
||||
is_internal: true,
|
||||
}),
|
||||
},
|
||||
);
|
||||
const data = (await response.json()) as SupportTicketCommentRecord | { message?: string };
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
'message' in data && data.message ? data.message : '댓글 저장에 실패했습니다.',
|
||||
);
|
||||
}
|
||||
|
||||
setComments((current) => [...current, data as SupportTicketCommentRecord]);
|
||||
setCommentDraft('');
|
||||
toast.success('관리자 댓글이 저장되었습니다.');
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : '댓글 저장 중 오류가 발생했습니다.',
|
||||
);
|
||||
} finally {
|
||||
setIsCommentSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateComment = async (commentId: number) => {
|
||||
if (!workspaceCode || !Number.isFinite(feedbackId) || isCommentSaving) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextContent = editingCommentDraft.trim();
|
||||
|
||||
if (!nextContent) {
|
||||
toast.error('댓글 내용을 입력해 주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCommentSaving(true);
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/support/tickets/${feedbackId}/comments/${commentId}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
requester_id: SUPPORT_TEST_ADMIN_USER_EMAIL,
|
||||
requester_tenant_id: SUPPORT_TEST_TENANT_ID,
|
||||
content: nextContent,
|
||||
}),
|
||||
},
|
||||
);
|
||||
const data = (await response.json()) as SupportTicketCommentRecord | { message?: string };
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
'message' in data && data.message ? data.message : '댓글 수정에 실패했습니다.',
|
||||
);
|
||||
}
|
||||
|
||||
setComments((current) =>
|
||||
current.map((comment) =>
|
||||
comment.comment_id === commentId ? (data as SupportTicketCommentRecord) : comment,
|
||||
),
|
||||
);
|
||||
setEditingCommentId(null);
|
||||
setEditingCommentDraft('');
|
||||
toast.success('관리자 댓글이 수정되었습니다.');
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : '댓글 수정 중 오류가 발생했습니다.',
|
||||
);
|
||||
} finally {
|
||||
setIsCommentSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteComment = async (commentId: number) => {
|
||||
if (!workspaceCode || !Number.isFinite(feedbackId) || isCommentSaving) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.confirm('이 댓글을 삭제하시겠습니까?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCommentSaving(true);
|
||||
|
||||
try {
|
||||
const query = new URLSearchParams({
|
||||
requesterId: SUPPORT_TEST_ADMIN_USER_EMAIL,
|
||||
requesterTenantId: SUPPORT_TEST_TENANT_ID,
|
||||
});
|
||||
const response = await fetch(
|
||||
`/api/support/tickets/${feedbackId}/comments/${commentId}?${query.toString()}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const data = (await response.json()) as { message?: string };
|
||||
throw new Error(data.message ?? '댓글 삭제에 실패했습니다.');
|
||||
}
|
||||
|
||||
setComments((current) =>
|
||||
current.filter((comment) => comment.comment_id !== commentId),
|
||||
);
|
||||
if (editingCommentId === commentId) {
|
||||
setEditingCommentId(null);
|
||||
setEditingCommentDraft('');
|
||||
}
|
||||
toast.success('관리자 댓글이 삭제되었습니다.');
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : '댓글 삭제 중 오류가 발생했습니다.',
|
||||
);
|
||||
} finally {
|
||||
setIsCommentSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={isOpen} onOpenChange={close}>
|
||||
<SheetContent className="max-w-[800px]">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="flex items-center gap-2">
|
||||
{t('v2.text.name.detail', { name: 'Feedback' })}
|
||||
<Tag
|
||||
variant="outline"
|
||||
size="small"
|
||||
className="cursor-pointer"
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(
|
||||
`${window.location.origin}/${window.location.pathname}?queries=${JSON.stringify([{ key: 'id', value: feedback.id, condition: 'IS' }])}&channelId=${channelId}`,
|
||||
);
|
||||
toast(t('v2.toast.copy'), {
|
||||
icon: <Icon name="RiCheckboxMultipleFill" />,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Copy URL
|
||||
</Tag>
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<SheetBody>
|
||||
<SheetDetailTable
|
||||
data={currentFeedback}
|
||||
rows={
|
||||
fields
|
||||
.filter((v) => DEFAULT_FIELD_KEYS.includes(v.key))
|
||||
.map((v) =>
|
||||
v.key === 'issues' ?
|
||||
{ ...v, format: 'issue', feedbackId: feedback.id }
|
||||
: v.format === 'aiField' ?
|
||||
{ ...v, format: 'aiField', refetch: refetchFeedback }
|
||||
: v,
|
||||
) as SheetDetailTableRow[]
|
||||
}
|
||||
/>
|
||||
<Divider variant="subtle" className="my-4" />
|
||||
<SheetDetailTable
|
||||
rows={
|
||||
fields
|
||||
.filter((v) => !DEFAULT_FIELD_KEYS.includes(v.key))
|
||||
.map((v) => ({
|
||||
...v,
|
||||
editable: v.property === 'EDITABLE',
|
||||
disabled:
|
||||
v.property === 'EDITABLE' && v.status === 'INACTIVE',
|
||||
refetch: v.format === 'aiField' ? refetchFeedback : undefined,
|
||||
})) as SheetDetailTableRow[]
|
||||
}
|
||||
mode={mode}
|
||||
data={currentFeedback}
|
||||
onChange={(key, value) =>
|
||||
setCurrentFeedback((prev) => ({ ...prev, [key]: value }))
|
||||
}
|
||||
/>
|
||||
{workspaceCode && Number.isFinite(feedbackId) && (
|
||||
<>
|
||||
<Divider variant="subtle" className="my-4" />
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">Comments</p>
|
||||
<p className="text-xs text-neutral-secondary">
|
||||
실제 ABC 콘솔 상세 화면에서 관리자 내부 댓글을 남길 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
<Tag variant="outline" size="small">
|
||||
{comments.length} items
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
{isCommentsLoading ? (
|
||||
<div className="text-sm text-neutral-secondary">댓글을 불러오는 중입니다...</div>
|
||||
) : comments.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-neutral-border p-4 text-sm text-neutral-secondary">
|
||||
아직 등록된 댓글이 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{comments
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((comment) => {
|
||||
const isOwnInternalComment =
|
||||
comment.is_internal &&
|
||||
comment.author_id === SUPPORT_TEST_ADMIN_USER_EMAIL &&
|
||||
comment.author_tenant_id === SUPPORT_TEST_TENANT_ID;
|
||||
|
||||
return (
|
||||
<div key={comment.comment_id} className="rounded-xl border border-neutral-border bg-neutral-background p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium">{comment.author_name}</p>
|
||||
<Tag variant={comment.is_internal ? 'secondary' : 'outline'} size="small">
|
||||
{comment.is_internal ? 'Internal' : 'User'}
|
||||
</Tag>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-xs text-neutral-secondary">
|
||||
{dayjs(comment.created_at).format('YYYY-MM-DD HH:mm')}
|
||||
</p>
|
||||
{isOwnInternalComment && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditingCommentId(comment.comment_id);
|
||||
setEditingCommentDraft(comment.content);
|
||||
}}
|
||||
className="text-xs font-medium text-primary"
|
||||
>
|
||||
수정
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleDeleteComment(comment.comment_id)}
|
||||
className="text-xs font-medium text-tint-red"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editingCommentId === comment.comment_id ? (
|
||||
<div className="mt-3 space-y-3">
|
||||
<textarea
|
||||
value={editingCommentDraft}
|
||||
onChange={(event) => setEditingCommentDraft(event.target.value)}
|
||||
rows={4}
|
||||
className="w-full resize-none rounded-lg border border-neutral-border bg-background px-3 py-2 text-sm outline-none transition focus:border-primary"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setEditingCommentId(null);
|
||||
setEditingCommentDraft('');
|
||||
}}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void handleUpdateComment(comment.comment_id)}
|
||||
loading={isCommentSaving}
|
||||
>
|
||||
수정 저장
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-2 whitespace-pre-line text-sm">{comment.content}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 rounded-xl border border-neutral-border p-4">
|
||||
<p className="text-sm font-medium">관리자 댓글 작성</p>
|
||||
<textarea
|
||||
value={commentDraft}
|
||||
onChange={(event) => setCommentDraft(event.target.value)}
|
||||
rows={4}
|
||||
placeholder="처리 메모나 운영 코멘트를 남겨 주세요."
|
||||
className="w-full resize-none rounded-lg border border-neutral-border bg-background px-3 py-2 text-sm outline-none transition focus:border-primary"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleCreateComment} loading={isCommentSaving}>
|
||||
관리자 댓글 저장
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</SheetBody>
|
||||
<SheetFooter>
|
||||
{onClickDelete && (
|
||||
<div className="flex-1">
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={openDeleteDialog}
|
||||
disabled={!perms.includes('feedback_delete')}
|
||||
>
|
||||
{t('v2.button.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{mode === 'edit' && (
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClickCancel}>
|
||||
{t('v2.button.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isObjectEqual(
|
||||
{ ...feedback, updatedAt: '' },
|
||||
{ ...currentFeedback, updatedAt: '' },
|
||||
)}
|
||||
onClick={onClickSubmit}
|
||||
loading={isLoading}
|
||||
>
|
||||
{t('v2.button.save')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{mode === 'view' && (
|
||||
<>
|
||||
<SheetClose>{t('v2.button.cancel')}</SheetClose>
|
||||
<Button
|
||||
onClick={() => setMode('edit')}
|
||||
disabled={!perms.includes('feedback_update')}
|
||||
>
|
||||
{t('v2.button.edit')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeedbackDetailSheet;
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { Button, Icon, Tabs, TabsList, TabsTrigger } from '@ufb/react';
|
||||
|
||||
interface Props {
|
||||
channels: { id: number; name: string }[];
|
||||
currentChannelId: number;
|
||||
setCurrentChannelId: (id: number) => void;
|
||||
totalItems?: number;
|
||||
}
|
||||
|
||||
const FeedbackTableChannelSelection = (props: Props) => {
|
||||
const { channels, currentChannelId, setCurrentChannelId, totalItems } = props;
|
||||
const { t } = useTranslation();
|
||||
const tabsContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [showScrollButtons, setShowScrollButtons] = useState(false);
|
||||
const [showLeftGradient, setShowLeftGradient] = useState(false);
|
||||
const [showRightGradient, setShowRightGradient] = useState(false);
|
||||
|
||||
const checkScrollState = () => {
|
||||
const container = tabsContainerRef.current;
|
||||
if (container) {
|
||||
const isScrollable = container.scrollWidth > container.clientWidth;
|
||||
setShowScrollButtons(isScrollable);
|
||||
|
||||
if (isScrollable) {
|
||||
setShowLeftGradient(container.scrollLeft > 0);
|
||||
setShowRightGradient(
|
||||
0.5 + container.scrollLeft + container.clientWidth <
|
||||
container.scrollWidth,
|
||||
);
|
||||
} else {
|
||||
setShowLeftGradient(false);
|
||||
setShowRightGradient(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const container = tabsContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
checkScrollState();
|
||||
});
|
||||
|
||||
resizeObserver.observe(container);
|
||||
|
||||
checkScrollState();
|
||||
container.addEventListener('scroll', checkScrollState);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
container.removeEventListener('scroll', checkScrollState);
|
||||
};
|
||||
}, [channels, tabsContainerRef]);
|
||||
|
||||
const handleScroll = (direction: 'left' | 'right') => {
|
||||
const container = tabsContainerRef.current;
|
||||
if (container) {
|
||||
const scrollAmount = 200;
|
||||
if (direction === 'left') {
|
||||
container.scrollLeft -= scrollAmount;
|
||||
} else {
|
||||
container.scrollLeft += scrollAmount;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center overflow-auto">
|
||||
<div className="scrollbar-hide relative flex-1 overflow-auto">
|
||||
{showLeftGradient && (
|
||||
<div className="pointer-events-none absolute bottom-0 left-0 top-0 z-10 w-12 bg-gradient-to-r from-[var(--bg-neutral-primary)] to-transparent" />
|
||||
)}
|
||||
|
||||
<Tabs
|
||||
value={String(currentChannelId)}
|
||||
onValueChange={(v) => setCurrentChannelId(Number(v))}
|
||||
className="scrollbar-hide overflow-auto"
|
||||
ref={tabsContainerRef}
|
||||
>
|
||||
<TabsList className="scrollbar-hide">
|
||||
{channels.length === 0 ?
|
||||
<TabsTrigger value="-1">
|
||||
{t('v2.text.no-data.channel')}
|
||||
</TabsTrigger>
|
||||
: channels.map((channel) => (
|
||||
<TabsTrigger key={channel.id} value={String(channel.id)}>
|
||||
{channel.name}
|
||||
{currentChannelId === channel.id && (
|
||||
<span className="ml-1 font-bold">{totalItems}</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
))
|
||||
}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
{showRightGradient && (
|
||||
<div className="pointer-events-none absolute bottom-0 right-0 top-0 z-10 w-12 bg-gradient-to-l from-[var(--bg-neutral-primary)] to-transparent" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showScrollButtons && (
|
||||
<div className="ml-2 flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
radius="large"
|
||||
onClick={() => handleScroll('left')}
|
||||
aria-label="Scroll left"
|
||||
disabled={!showLeftGradient}
|
||||
>
|
||||
<Icon name="RiArrowLeftLine" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
radius="large"
|
||||
onClick={() => handleScroll('right')}
|
||||
aria-label="Scroll right"
|
||||
disabled={!showRightGradient}
|
||||
>
|
||||
<Icon name="RiArrowRightLine" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeedbackTableChannelSelection;
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import type { Table } from '@tanstack/react-table';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownContent,
|
||||
DropdownItem,
|
||||
DropdownLabel,
|
||||
DropdownTrigger,
|
||||
Icon,
|
||||
toast,
|
||||
} from '@ufb/react';
|
||||
|
||||
import type { SearchQuery, TableFilterOperator } from '@/shared';
|
||||
import type { Field } from '@/entities/field';
|
||||
|
||||
import type { Feedback } from '../feedback.type';
|
||||
import useFeedbackDownload from '../lib/use-feedback-download';
|
||||
|
||||
interface Props {
|
||||
fields: Field[];
|
||||
defaultQueries: SearchQuery[];
|
||||
queries: SearchQuery[];
|
||||
disabled: boolean;
|
||||
table: Table<Feedback>;
|
||||
operator: TableFilterOperator;
|
||||
totalItems: number;
|
||||
}
|
||||
|
||||
const FeedbackTableDownload = (props: Props) => {
|
||||
const {
|
||||
fields,
|
||||
queries,
|
||||
disabled,
|
||||
table,
|
||||
operator,
|
||||
totalItems,
|
||||
defaultQueries,
|
||||
} = props;
|
||||
const router = useRouter();
|
||||
const { channelId, projectId } = router.query;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { mutateAsync: download } = useFeedbackDownload({
|
||||
params: { channelId: Number(channelId), projectId: Number(projectId) },
|
||||
});
|
||||
|
||||
const visibleColumns = table.getVisibleFlatColumns();
|
||||
const { rowSelection } = table.getState();
|
||||
const feedbackIds = useMemo(
|
||||
() =>
|
||||
Object.entries(rowSelection)
|
||||
.filter(([_, v]) => !!v)
|
||||
.map(([k]) => Number(k)),
|
||||
[rowSelection],
|
||||
);
|
||||
|
||||
const fieldIds = useMemo(
|
||||
() =>
|
||||
fields
|
||||
.filter((v) => visibleColumns.some((column) => column.id === v.key))
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((v) => v.id),
|
||||
[fields, visibleColumns],
|
||||
);
|
||||
|
||||
const exportFeedbackResponse = (type: 'xlsx' | 'csv') => () => {
|
||||
void toast.promise(
|
||||
download({
|
||||
type,
|
||||
fieldIds,
|
||||
defaultQueries,
|
||||
queries,
|
||||
operator,
|
||||
filterFeedbackIds: feedbackIds.length > 0 ? feedbackIds : undefined,
|
||||
}),
|
||||
{
|
||||
success: () => t('main.feedback.download.success'),
|
||||
error: () => t('main.feedback.download.error'),
|
||||
loading: t('main.feedback.download.loading'),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dropdown>
|
||||
<DropdownTrigger disabled={disabled}>
|
||||
<Icon name="RiDownload2Line" />
|
||||
Export
|
||||
</DropdownTrigger>
|
||||
<DropdownContent>
|
||||
<DropdownLabel>
|
||||
{feedbackIds.length > 0 ? feedbackIds.length : totalItems} Items
|
||||
</DropdownLabel>
|
||||
<DropdownItem onClick={exportFeedbackResponse('xlsx')}>
|
||||
Excel
|
||||
</DropdownItem>
|
||||
<DropdownItem onClick={exportFeedbackResponse('csv')}>CSV</DropdownItem>
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeedbackTableDownload;
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { Table } from '@tanstack/react-table';
|
||||
|
||||
import { Button, Icon } from '@ufb/react';
|
||||
|
||||
import { cn } from '@/shared';
|
||||
|
||||
import type { Feedback } from '../feedback.type';
|
||||
|
||||
interface Props {
|
||||
table: Table<Feedback>;
|
||||
}
|
||||
|
||||
const FeedbackTableExpand = ({ table }: Props) => {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn({
|
||||
'!bg-neutral-tertiary': table.getIsAllRowsExpanded(),
|
||||
})}
|
||||
onClick={() => table.toggleAllRowsExpanded()}
|
||||
>
|
||||
<Icon name="RiLineHeight" />
|
||||
Expand
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeedbackTableExpand;
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { Table } from '@tanstack/react-table';
|
||||
|
||||
import {
|
||||
Badge,
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
ComboboxList,
|
||||
ComboboxSelectItem,
|
||||
ComboboxTrigger,
|
||||
Icon,
|
||||
} from '@ufb/react';
|
||||
|
||||
import type { FieldInfo } from '@/entities/field';
|
||||
import { FieldFormatLabel } from '@/entities/field';
|
||||
|
||||
import type { Feedback } from '../feedback.type';
|
||||
|
||||
interface Props {
|
||||
table: Table<Feedback>;
|
||||
fields: FieldInfo[];
|
||||
}
|
||||
|
||||
const FeedbackTableViewOptions = ({ table, fields }: Props) => {
|
||||
return (
|
||||
<Combobox>
|
||||
<ComboboxTrigger trigger="click">
|
||||
<Icon name="RiEyeLine" />
|
||||
View
|
||||
<Badge variant="subtle">{table.getVisibleFlatColumns().length}</Badge>
|
||||
</ComboboxTrigger>
|
||||
<ComboboxContent>
|
||||
<ComboboxList maxHeight="400px">
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter(
|
||||
(column) =>
|
||||
typeof column.accessorFn !== 'undefined' && column.getCanHide(),
|
||||
)
|
||||
.map((column) => {
|
||||
const field = fields.find((f) => f.key === column.id);
|
||||
if (!field) return null;
|
||||
return (
|
||||
<ComboboxSelectItem
|
||||
key={column.id}
|
||||
onSelect={() =>
|
||||
column.toggleVisibility(!column.getIsVisible())
|
||||
}
|
||||
checked={column.getIsVisible()}
|
||||
>
|
||||
<FieldFormatLabel format={field.format} name={field.name} />
|
||||
</ComboboxSelectItem>
|
||||
);
|
||||
})}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeedbackTableViewOptions;
|
||||
@@ -0,0 +1,415 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type {
|
||||
OnChangeFn,
|
||||
PaginationState,
|
||||
Updater,
|
||||
VisibilityState,
|
||||
} from '@tanstack/react-table';
|
||||
import {
|
||||
getCoreRowModel,
|
||||
getExpandedRowModel,
|
||||
getPaginationRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table';
|
||||
import { useOverlay } from '@toss/use-overlay';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { Badge, Button, Icon, toast } from '@ufb/react';
|
||||
|
||||
import type { TableFilterField } from '@/shared';
|
||||
import {
|
||||
BasicTable,
|
||||
DateRangePicker,
|
||||
DeleteDialog,
|
||||
TableFilterPopover,
|
||||
TablePagination,
|
||||
useOAIMutation,
|
||||
usePermissions,
|
||||
useSort,
|
||||
} from '@/shared';
|
||||
import type { Channel } from '@/entities/channel';
|
||||
import type { Field } from '@/entities/field';
|
||||
|
||||
import { AISparklingIcon } from '@/assets';
|
||||
import { getColumns } from '../feedback-table-columns';
|
||||
import {
|
||||
useAIFIeldFeedbackCellLoading,
|
||||
useFeedbackQueryConverter,
|
||||
useFeedbackSearch,
|
||||
} from '../lib';
|
||||
import FeedbackDetailSheet from './feedback-detail-sheet.ui';
|
||||
import FeedbackTableChannelSelection from './feedback-table-channel-selection.ui';
|
||||
import FeedbackTableDownload from './feedback-table-download.ui';
|
||||
import FeedbackTableExpand from './feedback-table-expand.ui';
|
||||
import FeedbackTableViewOptions from './feedback-table-view-options.ui';
|
||||
|
||||
interface Props {
|
||||
projectId: number;
|
||||
channels: Channel[];
|
||||
currentChannel: Channel;
|
||||
setCurrentChannelId: (id: number) => void;
|
||||
filterFields: TableFilterField[];
|
||||
fields: Field[];
|
||||
}
|
||||
const FeedbackTable = (props: Props) => {
|
||||
const {
|
||||
channels,
|
||||
currentChannel,
|
||||
setCurrentChannelId,
|
||||
projectId,
|
||||
filterFields,
|
||||
fields,
|
||||
} = props;
|
||||
|
||||
const { t } = useTranslation();
|
||||
const perms = usePermissions();
|
||||
|
||||
const [openFeedbackId, setOpenFeedbackId] = useState<number | null>(null);
|
||||
|
||||
const [rows, setRows] = useState<Record<string, unknown>[]>([]);
|
||||
const [rowCount, setRowCount] = useState(0);
|
||||
const [pageCount, setPageCount] = useState(0);
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 20,
|
||||
});
|
||||
const [columnVisibility, setColumnVisibility] = useState<
|
||||
Record<number, VisibilityState>
|
||||
>({});
|
||||
|
||||
const onChangeColumnVisibility: OnChangeFn<VisibilityState> = (
|
||||
updaterOrValue: Updater<VisibilityState>,
|
||||
) => {
|
||||
if (typeof updaterOrValue !== 'function') {
|
||||
setColumnVisibility({
|
||||
...columnVisibility,
|
||||
[currentChannel.id]: updaterOrValue,
|
||||
});
|
||||
} else {
|
||||
setColumnVisibility((prev) => ({
|
||||
...prev,
|
||||
[currentChannel.id]: updaterOrValue(
|
||||
columnVisibility[currentChannel.id] ?? {},
|
||||
),
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const {
|
||||
queries,
|
||||
tableFilters,
|
||||
dateRange,
|
||||
operator,
|
||||
updateTableFilters,
|
||||
updateDateRange,
|
||||
defaultQueries,
|
||||
} = useFeedbackQueryConverter({
|
||||
projectId,
|
||||
filterFields,
|
||||
feedbackSearchMaxDays: currentChannel.feedbackSearchMaxDays,
|
||||
});
|
||||
|
||||
const onChangeCurrentChannelId = (channelId: number) => {
|
||||
setCurrentChannelId(channelId);
|
||||
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
|
||||
table.resetRowSelection();
|
||||
};
|
||||
|
||||
const onChangeTableFilters: typeof updateTableFilters = async (...input) => {
|
||||
await updateTableFilters(...input);
|
||||
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
|
||||
table.resetRowSelection();
|
||||
};
|
||||
|
||||
const onChangeDateRange: typeof updateDateRange = async (...input) => {
|
||||
await updateDateRange(...input);
|
||||
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
|
||||
table.resetRowSelection();
|
||||
};
|
||||
|
||||
const columns = useMemo(() => getColumns(fields), [fields]);
|
||||
|
||||
const table = useReactTable({
|
||||
columns,
|
||||
data: rows,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getExpandedRowModel: getExpandedRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
rowCount,
|
||||
state: {
|
||||
pagination,
|
||||
columnVisibility: columnVisibility[currentChannel.id],
|
||||
},
|
||||
initialState: { sorting: [{ id: 'createdAt', desc: true }] },
|
||||
manualSorting: true,
|
||||
onPaginationChange: setPagination,
|
||||
onColumnVisibilityChange: onChangeColumnVisibility,
|
||||
getRowId: (row) => String(row.id),
|
||||
});
|
||||
|
||||
const { rowSelection, sorting } = table.getState();
|
||||
const sort = useSort(sorting);
|
||||
|
||||
const selectedRowIds = useMemo(() => {
|
||||
return Object.entries(rowSelection).reduce(
|
||||
(acc, [key, value]) => (value ? acc.concat(Number(key)) : acc),
|
||||
[] as number[],
|
||||
);
|
||||
}, [rowSelection]);
|
||||
|
||||
const {
|
||||
data: feedbackData,
|
||||
isLoading,
|
||||
refetch,
|
||||
} = useFeedbackSearch(
|
||||
projectId,
|
||||
currentChannel.id,
|
||||
{
|
||||
queries,
|
||||
operator,
|
||||
sort,
|
||||
page: pagination.pageIndex + 1,
|
||||
limit: pagination.pageSize,
|
||||
defaultQueries,
|
||||
},
|
||||
{
|
||||
staleTime: 1000 * 60,
|
||||
throwOnError(error) {
|
||||
if (error.code === 'LargeWindow') {
|
||||
toast.error('Please narrow down the search range.');
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setRows(feedbackData?.items ?? []);
|
||||
setPageCount(feedbackData?.meta.totalPages ?? 0);
|
||||
setRowCount(feedbackData?.meta.totalItems ?? 0);
|
||||
table.resetRowSelection();
|
||||
}, [feedbackData]);
|
||||
|
||||
const { mutateAsync: deleteFeedback } = useOAIMutation({
|
||||
method: 'delete',
|
||||
path: '/api/admin/projects/{projectId}/channels/{channelId}/feedbacks',
|
||||
pathParams: { channelId: currentChannel.id, projectId },
|
||||
queryOptions: {
|
||||
onSuccess: async () => {
|
||||
await refetch();
|
||||
toast.success(t('v2.toast.success'));
|
||||
table.resetRowSelection();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const currentFeedback = useMemo(
|
||||
() => rows.find((v) => v.id === openFeedbackId),
|
||||
[rows, openFeedbackId],
|
||||
);
|
||||
|
||||
const { mutateAsync: updateFeedback } = useOAIMutation({
|
||||
method: 'put',
|
||||
path: '/api/admin/projects/{projectId}/channels/{channelId}/feedbacks/{feedbackId}',
|
||||
pathParams: {
|
||||
channelId: currentChannel.id,
|
||||
feedbackId: (currentFeedback?.id ?? 0) as number,
|
||||
projectId,
|
||||
},
|
||||
queryOptions: {
|
||||
onSuccess: async () => {
|
||||
await refetch();
|
||||
toast.success(t('v2.toast.success'));
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-4">
|
||||
<div className="flex flex-shrink justify-between gap-2">
|
||||
<FeedbackTableChannelSelection
|
||||
channels={channels}
|
||||
currentChannelId={currentChannel.id}
|
||||
setCurrentChannelId={onChangeCurrentChannelId}
|
||||
totalItems={feedbackData?.meta.totalItems ?? 0}
|
||||
/>
|
||||
<div className="flex flex-shrink-0 gap-2 [&>button]:min-w-20 [&>button]:flex-shrink-0">
|
||||
{selectedRowIds.length > 0 && (
|
||||
<>
|
||||
<FeedbackDeleteButton
|
||||
selectedRowIds={selectedRowIds}
|
||||
deleteFeedback={deleteFeedback}
|
||||
/>
|
||||
{fields.filter(
|
||||
(v) => v.format === 'aiField' && v.status === 'ACTIVE',
|
||||
).length > 0 && (
|
||||
<RunAIButton
|
||||
selectedRowIds={selectedRowIds}
|
||||
projectId={projectId}
|
||||
afterProcess={() => {
|
||||
table.resetRowSelection();
|
||||
void refetch();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<DateRangePicker
|
||||
onChange={onChangeDateRange}
|
||||
value={dateRange}
|
||||
maxDate={new Date()}
|
||||
maxDays={currentChannel.feedbackSearchMaxDays}
|
||||
allowEntirePeriod={currentChannel.feedbackSearchMaxDays === -1}
|
||||
tooltipContent={t('tooltip.feedback-date-picker-button')}
|
||||
/>
|
||||
<TableFilterPopover
|
||||
filterFields={filterFields.filter(
|
||||
(v) =>
|
||||
v.key === 'issueIds' ||
|
||||
table.getAllColumns().some((column) => column.id === v.key),
|
||||
)}
|
||||
operator={operator}
|
||||
onSubmit={onChangeTableFilters}
|
||||
tableFilters={tableFilters}
|
||||
table={table}
|
||||
/>
|
||||
<FeedbackTableViewOptions table={table} fields={fields} />
|
||||
<FeedbackTableExpand table={table} />
|
||||
<FeedbackTableDownload
|
||||
table={table}
|
||||
fields={fields}
|
||||
queries={queries}
|
||||
defaultQueries={defaultQueries}
|
||||
disabled={!perms.includes('feedback_download_read')}
|
||||
operator={operator}
|
||||
totalItems={feedbackData?.meta.totalItems ?? 0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<BasicTable
|
||||
table={table}
|
||||
onClickRow={(_, row) => setOpenFeedbackId(row.id as number)}
|
||||
isLoading={isLoading}
|
||||
isFiltered={queries.length > 0}
|
||||
resiable
|
||||
/>
|
||||
<TablePagination table={table} />
|
||||
{currentFeedback && (
|
||||
<FeedbackDetailSheet
|
||||
updateFeedback={(feedback) => updateFeedback(feedback as never)}
|
||||
onClickDelete={() =>
|
||||
deleteFeedback({ feedbackIds: [currentFeedback.id as number] })
|
||||
}
|
||||
isOpen={!!openFeedbackId}
|
||||
close={() => setOpenFeedbackId(null)}
|
||||
feedback={currentFeedback}
|
||||
fields={fields}
|
||||
projectId={projectId}
|
||||
channelId={currentChannel.id}
|
||||
refetchFeedback={refetch}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const FeedbackDeleteButton = (props: {
|
||||
selectedRowIds: number[];
|
||||
deleteFeedback: (params: { feedbackIds: number[] }) => Promise<void>;
|
||||
}) => {
|
||||
const { selectedRowIds, deleteFeedback } = props;
|
||||
const { t } = useTranslation();
|
||||
const perms = usePermissions();
|
||||
const overlay = useOverlay();
|
||||
const openDeleteFeedbacksDialog = () => {
|
||||
overlay.open(({ close, isOpen }) => (
|
||||
<DeleteDialog
|
||||
close={close}
|
||||
isOpen={isOpen}
|
||||
onClickDelete={() => deleteFeedback({ feedbackIds: selectedRowIds })}
|
||||
/>
|
||||
));
|
||||
};
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="!text-tint-red"
|
||||
onClick={openDeleteFeedbacksDialog}
|
||||
disabled={!perms.includes('feedback_delete')}
|
||||
>
|
||||
<Icon name="RiDeleteBin6Line" />
|
||||
{t('v2.button.name.delete', { name: 'Feedback' })}
|
||||
<Badge variant="subtle" className="!text-tint-red">
|
||||
{selectedRowIds.length}
|
||||
</Badge>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const RunAIButton = (props: {
|
||||
selectedRowIds: number[];
|
||||
projectId: number;
|
||||
afterProcess: () => void;
|
||||
}) => {
|
||||
const { selectedRowIds, projectId, afterProcess } = props;
|
||||
const { t } = useTranslation();
|
||||
const perms = usePermissions();
|
||||
const setLoadingFeedbackIds = useAIFIeldFeedbackCellLoading(
|
||||
(state) => state.setLoadingFeedbackIds,
|
||||
);
|
||||
|
||||
const { mutateAsync: processAI, isPending: isPendingAIProcess } =
|
||||
useOAIMutation({
|
||||
method: 'post',
|
||||
path: '/api/admin/projects/{projectId}/ai/process',
|
||||
pathParams: { projectId },
|
||||
queryOptions: {
|
||||
onSuccess() {
|
||||
toast.success(t('v2.toast.success'));
|
||||
},
|
||||
onSettled() {
|
||||
afterProcess();
|
||||
setLoadingFeedbackIds([]);
|
||||
},
|
||||
onError(error) {
|
||||
toast.error(error.message);
|
||||
},
|
||||
},
|
||||
});
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setLoadingFeedbackIds(selectedRowIds);
|
||||
toast.promise(processAI({ feedbackIds: selectedRowIds }), {
|
||||
loading: 'Loading',
|
||||
success: () => 'Success',
|
||||
});
|
||||
}}
|
||||
disabled={isPendingAIProcess || !perms.includes('feedback_update')}
|
||||
>
|
||||
<AISparklingIcon />
|
||||
Run AI
|
||||
<Badge variant="subtle">{selectedRowIds.length}</Badge>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeedbackTable;
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export { default as FeedbackDetailSheet } from './feedback-detail-sheet.ui';
|
||||
export { default as FeedbackTableDownload } from './feedback-table-download.ui';
|
||||
export { default as FeedbackTableExpand } from './feedback-table-expand.ui';
|
||||
export { default as FeedbackTableViewOptions } from './feedback-table-view-options.ui';
|
||||
export { default as FeedbackTableChannelSelection } from './feedback-table-channel-selection.ui';
|
||||
export { default as FeedbackTable } from './feedback-table.ui';
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import {
|
||||
Badge,
|
||||
Dropdown,
|
||||
DropdownContent,
|
||||
DropdownItem,
|
||||
DropdownTrigger,
|
||||
Icon,
|
||||
TextInput,
|
||||
toast,
|
||||
} from '@ufb/react';
|
||||
|
||||
import { useOAIMutation, usePermissions } from '@/shared';
|
||||
import type { Issue } from '@/entities/issue';
|
||||
|
||||
interface Props {
|
||||
issue: Issue;
|
||||
}
|
||||
|
||||
const IssueCellEditCombobox = (props: Props) => {
|
||||
const { issue } = props;
|
||||
|
||||
const { t } = useTranslation();
|
||||
const perms = usePermissions();
|
||||
const router = useRouter();
|
||||
const projectId = Number(router.query.projectId);
|
||||
|
||||
const [inputIssueName, setInputIssueName] = useState(issue.name);
|
||||
const queryClient = useQueryClient();
|
||||
const refetch = async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: [
|
||||
'/api/admin/projects/{projectId}/channels/{channelId}/feedbacks/search',
|
||||
],
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['/api/admin/projects/{projectId}/issues/search'],
|
||||
});
|
||||
};
|
||||
|
||||
const { mutate: update } = useOAIMutation({
|
||||
method: 'put',
|
||||
path: '/api/admin/projects/{projectId}/issues/{issueId}',
|
||||
pathParams: { issueId: issue.id, projectId },
|
||||
queryOptions: {
|
||||
async onSuccess() {
|
||||
await refetch();
|
||||
toast.success(t('v2.toast.success'));
|
||||
},
|
||||
onError(error) {
|
||||
toast.error(error.message);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: deleteIssue } = useOAIMutation({
|
||||
method: 'delete',
|
||||
path: '/api/admin/projects/{projectId}/issues/{issueId}',
|
||||
pathParams: { projectId, issueId: issue.id },
|
||||
queryOptions: {
|
||||
async onSuccess() {
|
||||
await refetch();
|
||||
toast.success(t('v2.toast.success'));
|
||||
},
|
||||
onError(error) {
|
||||
toast.error(error.message);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dropdown>
|
||||
<DropdownTrigger asChild>
|
||||
<button>
|
||||
<Badge onClick={(e) => e.stopPropagation()} variant="subtle">
|
||||
Edit
|
||||
</Badge>
|
||||
</button>
|
||||
</DropdownTrigger>
|
||||
<DropdownContent onClick={(e) => e.stopPropagation()} side="right">
|
||||
<div className="border-b">
|
||||
<TextInput
|
||||
className="border-none"
|
||||
value={inputIssueName}
|
||||
onChange={(e) => setInputIssueName(e.currentTarget.value)}
|
||||
disabled={!perms.includes('issue_update')}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.stopPropagation();
|
||||
const name = inputIssueName.trim();
|
||||
update({ ...issue, name });
|
||||
setInputIssueName(name);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<DropdownItem
|
||||
className="gap-2"
|
||||
onSelect={() => deleteIssue(undefined)}
|
||||
disabled={!perms.includes('issue_delete')}
|
||||
>
|
||||
<Icon name="RiDeleteBin5Fill" size={16} />
|
||||
Delete Issue
|
||||
</DropdownItem>
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default IssueCellEditCombobox;
|
||||
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useMemo, useState } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useThrottle } from 'react-use';
|
||||
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxGroup,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
ComboboxTrigger,
|
||||
Icon,
|
||||
Tag,
|
||||
toast,
|
||||
} from '@ufb/react';
|
||||
|
||||
import {
|
||||
client,
|
||||
cn,
|
||||
commandFilter,
|
||||
useOAIMutation,
|
||||
usePermissions,
|
||||
} from '@/shared';
|
||||
import { IssueBadge, useIssueSearchInfinite } from '@/entities/issue';
|
||||
import type { Issue } from '@/entities/issue';
|
||||
|
||||
import { useFeedbackSearch } from '../lib';
|
||||
import AiIssueComboboxGroup from './ai-issue-combobox-group.ui';
|
||||
|
||||
const InfiniteScrollArea = dynamic(
|
||||
() => import('@/shared/ui/infinite-scroll-area.ui'),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
interface IProps {
|
||||
issues?: Issue[];
|
||||
feedbackId?: number;
|
||||
isPreview?: boolean;
|
||||
}
|
||||
|
||||
const IssueCell: React.FC<IProps> = (props) => {
|
||||
const { feedbackId, issues, isPreview = false } = props;
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const router = useRouter();
|
||||
const projectId = Number(router.query.projectId);
|
||||
const channelId = Number(router.query.channelId);
|
||||
|
||||
const perms = usePermissions(projectId);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const throttledvalue = useThrottle(inputValue, 500);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data } = useFeedbackSearch(
|
||||
projectId,
|
||||
channelId,
|
||||
{
|
||||
queries: [{ key: 'id', value: feedbackId, condition: 'IS' }],
|
||||
operator: 'AND',
|
||||
},
|
||||
{ enabled: !issues },
|
||||
);
|
||||
|
||||
const currentIssues = (issues ?? data?.items[0]?.issues ?? []) as Issue[];
|
||||
|
||||
const refetch = async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: [
|
||||
'/api/admin/projects/{projectId}/channels/{channelId}/feedbacks/search',
|
||||
],
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['/api/admin/projects/{projectId}/issues/search'],
|
||||
});
|
||||
};
|
||||
|
||||
const {
|
||||
data: allIssueData,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useIssueSearchInfinite(Number(projectId), {
|
||||
limit: 50,
|
||||
queries: [{ key: 'name', value: throttledvalue, condition: 'CONTAINS' }],
|
||||
sort: { name: 'ASC' },
|
||||
});
|
||||
|
||||
const allIssues = useMemo(() => {
|
||||
return allIssueData.pages
|
||||
.map((v) => v?.items)
|
||||
.filter((v) => !!v)
|
||||
.flat()
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(currentIssues.some((issue) => issue.id === a.id) ? -1 : 1) -
|
||||
(currentIssues.some((issue) => issue.id === b.id) ? -1 : 1),
|
||||
);
|
||||
}, [allIssueData, currentIssues]);
|
||||
|
||||
const { mutate: attatchIssue } = useMutation({
|
||||
mutationFn: async ({ issueId }: { issueId: number }) => {
|
||||
if (!feedbackId) return;
|
||||
|
||||
const { data } = await client.post({
|
||||
path: '/api/admin/projects/{projectId}/channels/{channelId}/feedbacks/{feedbackId}/issue/{issueId}',
|
||||
pathParams: { projectId, channelId, feedbackId, issueId },
|
||||
});
|
||||
return data;
|
||||
},
|
||||
async onSuccess() {
|
||||
await refetch();
|
||||
toast.success(t('v2.toast.success'));
|
||||
},
|
||||
onError(error) {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
const { mutate: detachIssue } = useMutation({
|
||||
mutationFn: async ({ issueId }: { issueId: number }) => {
|
||||
if (!feedbackId) return;
|
||||
|
||||
const { data } = await client.delete({
|
||||
path: '/api/admin/projects/{projectId}/channels/{channelId}/feedbacks/{feedbackId}/issue/{issueId}',
|
||||
pathParams: { projectId, channelId, feedbackId, issueId },
|
||||
});
|
||||
return data;
|
||||
},
|
||||
async onSuccess() {
|
||||
await refetch();
|
||||
toast.success(t('v2.toast.success'));
|
||||
},
|
||||
onError(error) {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: createIssue } = useOAIMutation({
|
||||
method: 'post',
|
||||
path: '/api/admin/projects/{projectId}/issues',
|
||||
pathParams: { projectId },
|
||||
queryOptions: {
|
||||
async onSuccess() {
|
||||
await refetch();
|
||||
setInputValue('');
|
||||
toast.success(t('v2.toast.success'));
|
||||
},
|
||||
},
|
||||
});
|
||||
const onClickIssue = (issueId: number) => {
|
||||
if (currentIssues.some((v) => v.id === issueId)) {
|
||||
detachIssue({ issueId });
|
||||
} else {
|
||||
attatchIssue({ issueId });
|
||||
}
|
||||
};
|
||||
const onCreateIssue = async (name: string) => {
|
||||
const data = await createIssue({
|
||||
name,
|
||||
description: '',
|
||||
});
|
||||
if (!data) return;
|
||||
attatchIssue({ issueId: data.id });
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-wrap items-center gap-1 rounded"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Combobox>
|
||||
<ComboboxTrigger asChild>
|
||||
<button
|
||||
disabled={!perms.includes('feedback_issue_update')}
|
||||
className={cn({
|
||||
'opacity-50': !perms.includes('feedback_issue_update'),
|
||||
})}
|
||||
>
|
||||
{!isPreview && (
|
||||
<Tag
|
||||
variant="outline"
|
||||
className={cn('w-8 cursor-pointer justify-center', {
|
||||
'hover:bg-inherit': !perms.includes('feedback_issue_update'),
|
||||
})}
|
||||
>
|
||||
+
|
||||
</Tag>
|
||||
)}
|
||||
</button>
|
||||
</ComboboxTrigger>
|
||||
<ComboboxContent
|
||||
options={{ filter: commandFilter }}
|
||||
className="w-[320px]"
|
||||
>
|
||||
<ComboboxInput
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onValueChange={(value) => setInputValue(value)}
|
||||
value={inputValue}
|
||||
/>
|
||||
{!!feedbackId && !inputValue && (
|
||||
<AiIssueComboboxGroup
|
||||
projectId={projectId}
|
||||
channelId={channelId}
|
||||
feedbackId={feedbackId}
|
||||
onSelect={async ({ name, option, id }) => {
|
||||
if (option === 'CREATE') {
|
||||
await onCreateIssue(name);
|
||||
}
|
||||
if (option === 'EXISTING' && id) {
|
||||
onClickIssue(id);
|
||||
}
|
||||
}}
|
||||
currentIssues={currentIssues}
|
||||
/>
|
||||
)}
|
||||
<ComboboxList maxHeight="333px">
|
||||
<ComboboxGroup
|
||||
heading={
|
||||
<span className="text-neutral-tertiary text-base-normal">
|
||||
Issue List
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<ComboboxEmpty>{t('v2.text.no-data.default')}</ComboboxEmpty>
|
||||
{allIssues.map((issue) => (
|
||||
<ComboboxItem
|
||||
key={issue.id}
|
||||
onSelect={() => onClickIssue(issue.id)}
|
||||
value={issue.name}
|
||||
className="group justify-between"
|
||||
disabled={!perms.includes('feedback_issue_update')}
|
||||
>
|
||||
<IssueBadge
|
||||
key={issue.id}
|
||||
name={issue.name}
|
||||
status={issue.status}
|
||||
/>
|
||||
{currentIssues.some((v) => v.id === issue.id) ?
|
||||
<Icon name="RiCheckLine" size={16} />
|
||||
: <span className="text-small-normal text-neutral-tertiary opacity-0 transition-opacity group-hover:opacity-100">
|
||||
Add
|
||||
</span>
|
||||
}
|
||||
</ComboboxItem>
|
||||
))}
|
||||
|
||||
<InfiniteScrollArea
|
||||
fetchNextPage={fetchNextPage}
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
/>
|
||||
</ComboboxGroup>
|
||||
</ComboboxList>
|
||||
{!!inputValue &&
|
||||
perms.includes('issue_create') &&
|
||||
!currentIssues.some((issue) => issue.name === inputValue) &&
|
||||
!allIssues.some((issue) => issue.name === inputValue) && (
|
||||
<ComboboxItem
|
||||
value={inputValue}
|
||||
className="justify-between"
|
||||
onSelect={() => onCreateIssue(inputValue)}
|
||||
>
|
||||
<IssueBadge name={inputValue} status="INIT" />
|
||||
<span className="text-neutral-tertiary text-small-normal">
|
||||
Create
|
||||
</span>
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
{currentIssues.map((issue) => (
|
||||
<IssueBadge key={issue.id} name={issue.name} status={issue.status} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IssueCell;
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { createColumnHelper } from '@tanstack/react-table';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { Badge, Icon, Tag } from '@ufb/react';
|
||||
|
||||
import { DATE_TIME_FORMAT, displayString, RowDragHandleCell } from '@/shared';
|
||||
|
||||
import { FIELD_PROPERTY_TEXT } from './field-utils';
|
||||
import {
|
||||
FIELD_FORMAT_ICON_MAP,
|
||||
FIELD_STATUS_COLOR_MAP,
|
||||
} from './field.constant';
|
||||
import type { FieldInfo } from './field.type';
|
||||
import { AiFieldOptionPopover, SelectOptionListPopover } from './ui';
|
||||
|
||||
const DEFAULT_FIELD_COLUMNS_KEYS = ['id', 'createdAt', 'updatedAt', 'issues'];
|
||||
|
||||
const columnHelper = createColumnHelper<FieldInfo>();
|
||||
|
||||
export const getFieldColumns = (reorder?: (data: FieldInfo[]) => void) =>
|
||||
[
|
||||
reorder ?
|
||||
columnHelper.display({
|
||||
id: 'drag-handle',
|
||||
cell: ({ row }) => <RowDragHandleCell rowId={row.id} />,
|
||||
size: 30,
|
||||
enableSorting: false,
|
||||
meta: { truncate: false },
|
||||
})
|
||||
: null,
|
||||
columnHelper.accessor('key', {
|
||||
header: 'Key',
|
||||
cell: ({ getValue }) => (
|
||||
<div className="flex items-center gap-1">
|
||||
<span>{displayString(getValue())}</span>
|
||||
{DEFAULT_FIELD_COLUMNS_KEYS.includes(getValue()) && (
|
||||
<Badge variant="outline">Default</Badge>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
}),
|
||||
columnHelper.accessor('name', {
|
||||
header: 'Display Name',
|
||||
cell: ({ getValue }) => <span>{displayString(getValue())}</span>,
|
||||
enableSorting: false,
|
||||
}),
|
||||
columnHelper.accessor('format', {
|
||||
header: 'Format',
|
||||
cell: ({ getValue }) => (
|
||||
<div className="flex items-center gap-1">
|
||||
<Icon name={FIELD_FORMAT_ICON_MAP[getValue()]} size={16} />
|
||||
{getValue()}
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
}),
|
||||
columnHelper.display({
|
||||
header: 'Option',
|
||||
cell: ({ row }) => {
|
||||
if (
|
||||
(row.original.format === 'select' ||
|
||||
row.original.format === 'multiSelect') &&
|
||||
(row.original.options ?? []).length > 0
|
||||
) {
|
||||
return (
|
||||
<SelectOptionListPopover options={row.original.options ?? []} />
|
||||
);
|
||||
}
|
||||
if (row.original.format === 'aiField') {
|
||||
return (
|
||||
<AiFieldOptionPopover
|
||||
aiFieldTemplateId={row.original.aiFieldTemplateId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return '-';
|
||||
},
|
||||
enableSorting: false,
|
||||
meta: { truncate: false },
|
||||
}),
|
||||
columnHelper.accessor('property', {
|
||||
header: 'Property',
|
||||
cell: ({ getValue }) => (
|
||||
<Tag variant="secondary" radius="large">
|
||||
{FIELD_PROPERTY_TEXT[getValue()]}
|
||||
</Tag>
|
||||
),
|
||||
enableSorting: false,
|
||||
filterFn: (row, _, value: string[]) => {
|
||||
return value.some((property) => property === row.getValue('property'));
|
||||
},
|
||||
meta: { truncate: false },
|
||||
}),
|
||||
columnHelper.accessor('status', {
|
||||
header: 'Status',
|
||||
cell: ({ getValue }) => (
|
||||
<Badge color={FIELD_STATUS_COLOR_MAP[getValue()]} radius="large">
|
||||
{getValue()}
|
||||
</Badge>
|
||||
),
|
||||
enableSorting: false,
|
||||
filterFn: (row, _, value: string[]) => {
|
||||
return value.some((status) => status === row.getValue('status'));
|
||||
},
|
||||
meta: { truncate: false },
|
||||
}),
|
||||
columnHelper.accessor('description', {
|
||||
header: 'Description',
|
||||
cell: ({ getValue }) => <span>{displayString(getValue())}</span>,
|
||||
enableSorting: false,
|
||||
}),
|
||||
columnHelper.accessor('createdAt', {
|
||||
header: 'Created',
|
||||
cell: ({ getValue }) => (
|
||||
<span>
|
||||
{getValue() ? dayjs(getValue()).format(DATE_TIME_FORMAT) : '-'}
|
||||
</span>
|
||||
),
|
||||
}),
|
||||
].filter((v) => !!v);
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import type { FieldInfo, FieldProperty } from './field.type';
|
||||
|
||||
export const isDefaultField = (field: FieldInfo) =>
|
||||
field.key === 'id' ||
|
||||
field.key === 'createdAt' ||
|
||||
field.key === 'updatedAt' ||
|
||||
field.key === 'issues';
|
||||
|
||||
export const sortField = (a: FieldInfo, b: FieldInfo) => {
|
||||
return a.order - b.order;
|
||||
};
|
||||
|
||||
export const FIELD_PROPERTY_TEXT: Record<FieldProperty, string> = {
|
||||
READ_ONLY: 'Read Only',
|
||||
EDITABLE: 'Editable',
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import type { Color, IconNameType } from '@ufb/react';
|
||||
|
||||
import type { Field, FieldFormat } from './field.type';
|
||||
|
||||
export const FIELD_FORMAT_LIST = [
|
||||
'text',
|
||||
'keyword',
|
||||
'number',
|
||||
'date',
|
||||
'select',
|
||||
'multiSelect',
|
||||
'images',
|
||||
'aiField',
|
||||
] as const;
|
||||
|
||||
export const FIELD_FORMAT_ICON_MAP: Record<FieldFormat, IconNameType> = {
|
||||
text: 'RiText',
|
||||
keyword: 'RiFontSize',
|
||||
number: 'RiHashtag',
|
||||
date: 'RiCalendarEventLine',
|
||||
select: 'RiCheckboxCircleLine',
|
||||
multiSelect: 'RiListCheck',
|
||||
images: 'RiImageLine',
|
||||
aiField: 'RiSparklingFill',
|
||||
};
|
||||
|
||||
export const FIELD_STATUS_COLOR_MAP: Record<Field['status'], Color> = {
|
||||
ACTIVE: 'green',
|
||||
INACTIVE: 'red',
|
||||
};
|
||||
|
||||
export const DEFAULT_FIELD_KEYS = ['id', 'createdAt', 'updatedAt', 'issues'];
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { i18n } from 'next-i18next';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { FIELD_FORMAT_LIST } from './field.constant';
|
||||
|
||||
export const fieldOptionSchema = z.object({
|
||||
id: z.number(),
|
||||
key: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, { message: i18n?.t('hint.required') })
|
||||
.max(20, { message: i18n?.t('hint.max-length', { length: 20 }) }),
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, { message: i18n?.t('hint.required') })
|
||||
.max(20, { message: i18n?.t('hint.max-length', { length: 20 }) }),
|
||||
});
|
||||
|
||||
export const fieldSchema = z.object({
|
||||
id: z.number(),
|
||||
key: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, { message: i18n?.t('hint.required') })
|
||||
.max(20, { message: i18n?.t('hint.max-length', { length: 20 }) })
|
||||
.regex(
|
||||
/^[A-Za-z0-9_]*$/,
|
||||
'String can only contain letters, numbers, and underscores, with no spaces or other special characters.',
|
||||
),
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, { message: i18n?.t('hint.required') })
|
||||
.max(20, { message: i18n?.t('hint.max-length', { length: 20 }) }),
|
||||
description: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(50, { message: i18n?.t('hint.max-length', { length: 50 }) })
|
||||
.nullable(),
|
||||
format: z.enum(FIELD_FORMAT_LIST),
|
||||
property: z.enum(['READ_ONLY', 'EDITABLE']),
|
||||
status: z.enum(['ACTIVE', 'INACTIVE']),
|
||||
options: z.array(fieldOptionSchema).optional(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
order: z.number(),
|
||||
aiFieldTemplateId: z.number().nullable().optional(),
|
||||
aiFieldTargetKeys: z.array(z.string()).nullable().optional(),
|
||||
aiFieldAutoProcessing: z.boolean().nullable().optional(),
|
||||
});
|
||||
|
||||
export const fieldInfoSchema = fieldSchema
|
||||
.pick({
|
||||
key: true,
|
||||
name: true,
|
||||
description: true,
|
||||
format: true,
|
||||
property: true,
|
||||
status: true,
|
||||
options: true,
|
||||
order: true,
|
||||
aiFieldTemplateId: true,
|
||||
aiFieldTargetKeys: true,
|
||||
aiFieldAutoProcessing: true,
|
||||
})
|
||||
.merge(
|
||||
z.object({
|
||||
id: z.number().optional(),
|
||||
createdAt: z.string().optional(),
|
||||
options: z
|
||||
.array(
|
||||
fieldOptionSchema
|
||||
.pick({ key: true, name: true })
|
||||
.merge(z.object({ id: z.number().optional() })),
|
||||
)
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.refine(
|
||||
(data) =>
|
||||
data.format === 'select' || data.format === 'multiSelect' ?
|
||||
!!data.options && data.options.length > 0
|
||||
: true,
|
||||
{
|
||||
path: ['options'],
|
||||
message: 'Option is required.',
|
||||
},
|
||||
)
|
||||
.refine(
|
||||
(data) => (data.format === 'aiField' ? !!data.aiFieldTemplateId : true),
|
||||
{
|
||||
path: ['aiFieldTemplateId'],
|
||||
message: 'Template is required.',
|
||||
},
|
||||
)
|
||||
.refine(
|
||||
(data) =>
|
||||
data.format === 'aiField' ?
|
||||
!!data.aiFieldTargetKeys && data.aiFieldTargetKeys.length > 0
|
||||
: true,
|
||||
{
|
||||
path: ['aiFieldTargetKeys'],
|
||||
message: 'Target Fields are required.',
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import type { z } from 'zod';
|
||||
|
||||
import type {
|
||||
fieldInfoSchema,
|
||||
fieldOptionSchema,
|
||||
fieldSchema,
|
||||
} from './field.schema';
|
||||
|
||||
export type Field = z.infer<typeof fieldSchema>;
|
||||
export type FieldInfo = z.infer<typeof fieldInfoSchema>;
|
||||
export type FieldFormat = Field['format'];
|
||||
export type FieldProperty = Field['property'];
|
||||
export type FieldStatus = Field['status'];
|
||||
export type FieldOption = z.infer<typeof fieldOptionSchema>;
|
||||
|
||||
export type FieldOptionInfo = Omit<FieldOption, 'id'> & {
|
||||
id?: number;
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export * from './field.type';
|
||||
export * from './ui';
|
||||
export * from './field-utils';
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
import {
|
||||
Caption,
|
||||
Dropdown,
|
||||
DropdownContent,
|
||||
DropdownItem,
|
||||
DropdownTrigger,
|
||||
Icon,
|
||||
Tag,
|
||||
} from '@ufb/react';
|
||||
|
||||
import { useOAIQuery } from '@/shared';
|
||||
|
||||
interface Props {
|
||||
aiFieldTemplateId?: number | null;
|
||||
}
|
||||
|
||||
const AiFieldOptionPopover = ({ aiFieldTemplateId }: Props) => {
|
||||
const router = useRouter();
|
||||
const projectId = +(router.query.projectId as string);
|
||||
const { data } = useOAIQuery({
|
||||
path: '/api/admin/projects/{projectId}/ai/fieldTemplates',
|
||||
variables: { projectId },
|
||||
});
|
||||
|
||||
const aiFieldTemplate = data?.find((v) => v.id === aiFieldTemplateId);
|
||||
|
||||
if (!aiFieldTemplate) {
|
||||
return <Caption variant="error">Template is not found</Caption>;
|
||||
}
|
||||
return (
|
||||
<Dropdown>
|
||||
<DropdownTrigger asChild data-state="close">
|
||||
<Tag onClick={(e) => e.stopPropagation()}>
|
||||
Template Option
|
||||
<Icon name="RiInformation2Line" />
|
||||
</Tag>
|
||||
</DropdownTrigger>
|
||||
<DropdownContent>
|
||||
<DropdownItem>{aiFieldTemplate.title}</DropdownItem>
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default AiFieldOptionPopover;
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
/* eslint-disable no-useless-escape */
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Icon,
|
||||
Popover,
|
||||
PopoverClose,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@ufb/react';
|
||||
|
||||
import { useOAIQuery } from '@/shared';
|
||||
|
||||
import { env } from '@/env';
|
||||
|
||||
interface IProps {
|
||||
channelId: number;
|
||||
projectId: number;
|
||||
}
|
||||
|
||||
const FeedbackRequestCodePopover: React.FC<IProps> = (props) => {
|
||||
const { channelId, projectId } = props;
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: channelData } = useOAIQuery({
|
||||
path: '/api/admin/projects/{projectId}/channels/{channelId}',
|
||||
variables: { channelId, projectId },
|
||||
});
|
||||
|
||||
const { data: apiKeysData } = useOAIQuery({
|
||||
path: '/api/admin/projects/{projectId}/api-keys',
|
||||
variables: { projectId },
|
||||
});
|
||||
|
||||
const apiKey = useMemo(
|
||||
() => apiKeysData?.items.find((v) => !v.deletedAt) ?? null,
|
||||
[apiKeysData],
|
||||
);
|
||||
|
||||
const snippetBody = useMemo(() => {
|
||||
if (!channelData?.fields) return null;
|
||||
const body: Record<string, unknown> = {};
|
||||
for (const field of channelData.fields) {
|
||||
if (field.status === 'INACTIVE') continue;
|
||||
if (field.key === 'id') continue;
|
||||
if (field.key === 'updatedAt') continue;
|
||||
const key = field.key === 'issues' ? `'issueNames'` : `'${field.key}'`;
|
||||
switch (field.format) {
|
||||
case 'number':
|
||||
case 'date':
|
||||
body[key] = field.format;
|
||||
break;
|
||||
case 'keyword':
|
||||
case 'text':
|
||||
body[key] = 'string';
|
||||
break;
|
||||
case 'select':
|
||||
body[key] = field.options.map((v) => `'${v.name}'`).join(' or ');
|
||||
break;
|
||||
case 'multiSelect':
|
||||
body[key] = `[${field.options.map((v) => `'${v.name}'`).join(', ')}]`;
|
||||
break;
|
||||
case 'images':
|
||||
body[key] = `URL[]`;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
return body;
|
||||
}, [channelData]);
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<Icon name="RiCodeBlock" />
|
||||
{t('main.setting.feedback-request-code')}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="flex flex-col gap-5 p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h5 className="text-title-h5">
|
||||
{t('main.setting.feedback-request-code')}
|
||||
</h5>
|
||||
<PopoverClose asChild>
|
||||
<Button variant="ghost">
|
||||
<Icon name="RiCloseLine" />
|
||||
</Button>
|
||||
</PopoverClose>
|
||||
</div>
|
||||
{!apiKey && (
|
||||
<p className="text-tint-red">
|
||||
No API KEY with Active status. Please create API KEY.
|
||||
</p>
|
||||
)}
|
||||
<pre className="bg-neutral-tertiary text-small-normal whitespace-pre-wrap rounded p-4">
|
||||
{`curl --request POST ${
|
||||
env.NEXT_PUBLIC_API_BASE_URL
|
||||
}/api/projects/${projectId}/channels/${channelId}/feedbacks \\\n--header 'Content-Type: application/json' \\\n--header 'x-api-key: ${
|
||||
apiKey?.value ?? 'API_KEY'
|
||||
}' \\\n--data-raw '${
|
||||
snippetBody ?
|
||||
JSON.stringify(snippetBody, null, 4)
|
||||
.replace(/\"/g, '')
|
||||
.replace(/\'/g, '"')
|
||||
: ''
|
||||
}'`}
|
||||
</pre>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeedbackRequestCodePopover;
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { Icon } from '@ufb/react';
|
||||
|
||||
import { FIELD_FORMAT_ICON_MAP } from '../field.constant';
|
||||
import type { FieldFormat } from '../field.type';
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
format: FieldFormat;
|
||||
}
|
||||
|
||||
const FieldFormatLabel = ({ name, format }: Props) => {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<Icon name={FIELD_FORMAT_ICON_MAP[format]} size={16} />
|
||||
{name}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FieldFormatLabel;
|
||||
@@ -0,0 +1,486 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useOverlay } from '@toss/use-overlay';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Caption,
|
||||
Checkbox,
|
||||
FormField,
|
||||
Icon,
|
||||
Sheet,
|
||||
SheetBody,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
Switch,
|
||||
Tag,
|
||||
} from '@ufb/react';
|
||||
|
||||
import type { FormOverlayProps } from '@/shared';
|
||||
import {
|
||||
Card,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
DeleteDialog,
|
||||
Path,
|
||||
SelectInput,
|
||||
TextInput,
|
||||
useOAIQuery,
|
||||
} from '@/shared';
|
||||
import {
|
||||
FormInput,
|
||||
FormMultiSelect,
|
||||
FormSelect,
|
||||
} from '@/shared/ui/form-inputs';
|
||||
|
||||
import { FIELD_FORMAT_ICON_MAP, FIELD_FORMAT_LIST } from '../field.constant';
|
||||
import { fieldInfoSchema } from '../field.schema';
|
||||
import type { FieldInfo } from '../field.type';
|
||||
|
||||
const defaultValues: FieldInfo = {
|
||||
description: '',
|
||||
format: 'text',
|
||||
name: '',
|
||||
key: '',
|
||||
status: 'ACTIVE',
|
||||
property: 'READ_ONLY',
|
||||
order: 0,
|
||||
aiFieldTargetKeys: null,
|
||||
aiFieldTemplateId: null,
|
||||
aiFieldAutoProcessing: null,
|
||||
};
|
||||
|
||||
interface IProps extends FormOverlayProps<FieldInfo> {
|
||||
fieldRows: FieldInfo[];
|
||||
}
|
||||
|
||||
const FieldSettingSheet: React.FC<IProps> = (props) => {
|
||||
const {
|
||||
data,
|
||||
fieldRows,
|
||||
close,
|
||||
isOpen,
|
||||
onSubmit: onSave,
|
||||
onClickDelete,
|
||||
disabledUpdate,
|
||||
disabledDelete,
|
||||
} = props;
|
||||
|
||||
const router = useRouter();
|
||||
const projectId = parseInt(router.query.projectId as string, 10);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [isSameKey, setIsSameKey] = useState(
|
||||
data ? data.name === data.key : true,
|
||||
);
|
||||
const [optionInput, setOptionInput] = useState<string>('');
|
||||
const overlay = useOverlay();
|
||||
|
||||
const isOriginalData = useMemo(() => (data ? !!data.id : false), [data]);
|
||||
const isEditing = useMemo(() => !!data, [data]);
|
||||
const isDefaultField = useMemo(
|
||||
() =>
|
||||
data?.key === 'id' ||
|
||||
data?.key === 'issues' ||
|
||||
data?.key === 'createdAt' ||
|
||||
data?.key === 'updatedAt',
|
||||
[data],
|
||||
);
|
||||
|
||||
const otherFields = useMemo(
|
||||
() => fieldRows.filter((v) => v.key !== data?.key),
|
||||
[data, fieldRows],
|
||||
);
|
||||
|
||||
const methods = useForm<FieldInfo>({
|
||||
resolver: zodResolver(fieldInfoSchema),
|
||||
defaultValues: data ?? defaultValues,
|
||||
});
|
||||
|
||||
const {
|
||||
watch,
|
||||
setValue,
|
||||
handleSubmit,
|
||||
reset,
|
||||
setError,
|
||||
formState,
|
||||
clearErrors,
|
||||
control,
|
||||
} = methods;
|
||||
|
||||
useEffect(() => {
|
||||
if (isSameKey) setValue('name', watch('key'), { shouldDirty: true });
|
||||
}, [isSameKey, watch('key')]);
|
||||
|
||||
const addOption = () => {
|
||||
if (optionInput === '') return;
|
||||
|
||||
if (
|
||||
watch('options')?.find(
|
||||
(v) => v.name.toLowerCase() === optionInput.toLowerCase(),
|
||||
)
|
||||
) {
|
||||
setError('options', { message: 'Option Name is duplicated' });
|
||||
return;
|
||||
}
|
||||
setValue(
|
||||
'options',
|
||||
(watch('options') ?? []).concat({
|
||||
name: optionInput,
|
||||
key: optionInput,
|
||||
}),
|
||||
{ shouldDirty: true },
|
||||
);
|
||||
setOptionInput('');
|
||||
clearErrors('options');
|
||||
};
|
||||
|
||||
const removeOption = (targetIndex: number) => {
|
||||
overlay.open(({ close, isOpen }) => (
|
||||
<DeleteDialog
|
||||
close={close}
|
||||
isOpen={isOpen}
|
||||
onClickDelete={() => {
|
||||
setValue(
|
||||
'options',
|
||||
(watch('options') ?? []).filter((_, i) => i !== targetIndex),
|
||||
{ shouldDirty: true },
|
||||
);
|
||||
}}
|
||||
description={t('main.setting.dialog.delete-option.description')}
|
||||
/>
|
||||
));
|
||||
};
|
||||
|
||||
const onSubmit = async (input: FieldInfo) => {
|
||||
const checkDuplicatedKey = otherFields.find((v) => v.key === input.key);
|
||||
if (checkDuplicatedKey) {
|
||||
setError('key', { message: 'Key is duplicated' });
|
||||
return;
|
||||
}
|
||||
const checkDuplicatedName = otherFields.find((v) => v.name === input.name);
|
||||
if (checkDuplicatedName) {
|
||||
setError('name', { message: 'Name is duplicated' });
|
||||
return;
|
||||
}
|
||||
if (input.format === 'select' || input.format === 'multiSelect') {
|
||||
if (!input.options || input.options.length === 0) {
|
||||
setError('options', { message: 'Option is required' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
await onSave({ ...data, ...input });
|
||||
reset(defaultValues);
|
||||
close();
|
||||
};
|
||||
|
||||
const { data: aiIntegration } = useOAIQuery({
|
||||
path: '/api/admin/projects/{projectId}/ai/integrations',
|
||||
variables: { projectId },
|
||||
});
|
||||
|
||||
const { data: templates } = useOAIQuery({
|
||||
path: '/api/admin/projects/{projectId}/ai/fieldTemplates',
|
||||
variables: { projectId },
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
if (data.format === 'aiField' && !data.aiFieldTemplateId) {
|
||||
setError('aiFieldTemplateId', { message: 'Template is required' });
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<Sheet open={isOpen} onOpenChange={close}>
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
{isEditing ?
|
||||
t('main.setting.dialog.edit-field.title')
|
||||
: t('main.setting.dialog.add-field.title')}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<SheetBody asChild>
|
||||
<FormProvider {...methods}>
|
||||
<form
|
||||
id="field-setting"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="flex w-[450px] flex-col gap-4"
|
||||
>
|
||||
<FormField
|
||||
control={control}
|
||||
name="key"
|
||||
render={({ field }) => (
|
||||
<FormInput
|
||||
label="Key"
|
||||
{...field}
|
||||
disabled={isOriginalData || isDefaultField}
|
||||
required={!data}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<FormField
|
||||
control={control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormInput
|
||||
label="Display Name"
|
||||
{...field}
|
||||
disabled={isSameKey || isDefaultField}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Checkbox
|
||||
checked={isSameKey}
|
||||
onCheckedChange={(checked) => setIsSameKey(!!checked)}
|
||||
disabled={isDefaultField}
|
||||
>
|
||||
{t('main.setting.same-key')}
|
||||
</Checkbox>
|
||||
</div>
|
||||
<div>
|
||||
<SelectInput
|
||||
label="Format"
|
||||
onChange={(value) => {
|
||||
setValue('format', value as FieldInfo['format'], {
|
||||
shouldDirty: true,
|
||||
});
|
||||
setValue('options', undefined);
|
||||
setValue('aiFieldTemplateId', undefined);
|
||||
setValue('aiFieldTargetKeys', undefined);
|
||||
setValue('aiFieldAutoProcessing', undefined);
|
||||
}}
|
||||
options={FIELD_FORMAT_LIST.map((v) => ({
|
||||
label: v,
|
||||
value: v,
|
||||
icon: FIELD_FORMAT_ICON_MAP[v],
|
||||
disabled: v === 'aiField' && !aiIntegration?.apiKey,
|
||||
}))}
|
||||
value={watch('format')}
|
||||
disabled={isOriginalData || isDefaultField}
|
||||
required
|
||||
/>
|
||||
{watch('format') === 'images' && (
|
||||
<Caption>{t('hint.image-format')}</Caption>
|
||||
)}
|
||||
</div>
|
||||
{(watch('format') === 'select' ||
|
||||
watch('format') === 'multiSelect') &&
|
||||
!isDefaultField && (
|
||||
<div>
|
||||
<TextInput
|
||||
label="Select Option"
|
||||
value={optionInput}
|
||||
maxLength={20}
|
||||
onChange={(e) => setOptionInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addOption();
|
||||
}
|
||||
}}
|
||||
error={formState.errors.options?.message}
|
||||
rightButton={
|
||||
<Button onClick={addOption}>
|
||||
{t('button.register')}
|
||||
</Button>
|
||||
}
|
||||
required
|
||||
/>
|
||||
{(watch('options') ?? []).length > 0 && (
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{watch('options')?.map((v, i) => (
|
||||
<Tag key={i} radius="large" variant="secondary">
|
||||
{v.name}
|
||||
<Icon
|
||||
name="RiCloseLargeLine"
|
||||
onClick={() => removeOption(i)}
|
||||
/>
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{watch('format') === 'aiField' && (
|
||||
<>
|
||||
<div>
|
||||
<FormField
|
||||
control={control}
|
||||
name="aiFieldTemplateId"
|
||||
render={({ field }) => (
|
||||
<>
|
||||
<FormSelect
|
||||
label="Template"
|
||||
options={
|
||||
templates?.map(({ title, id }) => ({
|
||||
label: title,
|
||||
value: id.toString(),
|
||||
})) ?? []
|
||||
}
|
||||
disabled={isDefaultField}
|
||||
value={field.value?.toString()}
|
||||
onChange={(value) => {
|
||||
field.onChange(value ? parseInt(value) : null);
|
||||
clearErrors('aiFieldTemplateId');
|
||||
}}
|
||||
required
|
||||
/>
|
||||
<Link
|
||||
target="_blank"
|
||||
href={{
|
||||
pathname: Path.SETTINGS,
|
||||
query: {
|
||||
menu: 'generative-ai',
|
||||
subMenu: 'field-template',
|
||||
projectId,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Caption className="mt-1 flex items-center gap-0.5">
|
||||
<Icon name="RiExternalLinkFill" size={16} />
|
||||
Go to Template Settings
|
||||
</Caption>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
control={control}
|
||||
name="aiFieldTargetKeys"
|
||||
render={({ field }) => (
|
||||
<FormMultiSelect
|
||||
label="Target Field"
|
||||
options={fieldRows
|
||||
.filter(({ key }) => key !== watch('key'))
|
||||
.map(({ key, name }) => ({
|
||||
label: name,
|
||||
value: key,
|
||||
}))}
|
||||
{...field}
|
||||
disabled={isDefaultField}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Card size="sm">
|
||||
<CardHeader
|
||||
action={
|
||||
<Switch
|
||||
checked={watch('aiFieldAutoProcessing') ?? false}
|
||||
onCheckedChange={(checked) =>
|
||||
setValue('aiFieldAutoProcessing', checked, {
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<CardTitle>AI Field Automation</CardTitle>
|
||||
<CardDescription>
|
||||
{t('v2.description.ai-field-template-auto-processing')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
<FormField
|
||||
control={control}
|
||||
name="property"
|
||||
render={({ field }) => (
|
||||
<FormSelect
|
||||
label="Property"
|
||||
options={[
|
||||
{ label: 'Read Only', value: 'READ_ONLY' },
|
||||
{ label: 'Editable', value: 'EDITABLE' },
|
||||
]}
|
||||
{...field}
|
||||
disabled={isDefaultField}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={control}
|
||||
name="status"
|
||||
render={({ field }) => (
|
||||
<FormSelect
|
||||
label="Status"
|
||||
options={[
|
||||
{ label: 'Active', value: 'ACTIVE' },
|
||||
{ label: 'Inactive', value: 'INACTIVE' },
|
||||
]}
|
||||
{...field}
|
||||
disabled={isDefaultField}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormInput
|
||||
label="Description"
|
||||
{...field}
|
||||
disabled={isDefaultField}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</SheetBody>
|
||||
<SheetFooter>
|
||||
{data && (
|
||||
<div className="flex-1">
|
||||
<Button
|
||||
disabled={isOriginalData || isDefaultField || disabledDelete}
|
||||
variant="destructive"
|
||||
onClick={() => onClickDelete?.()}
|
||||
>
|
||||
{t('v2.button.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<SheetClose>{t('v2.button.cancel')}</SheetClose>
|
||||
<Button
|
||||
type="submit"
|
||||
form="field-setting"
|
||||
disabled={isDefaultField || !formState.isDirty || disabledUpdate}
|
||||
>
|
||||
{t('v2.button.confirm')}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
};
|
||||
|
||||
export default FieldSettingSheet;
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table';
|
||||
|
||||
import { Button, Icon } from '@ufb/react';
|
||||
|
||||
import { BasicTable, TableFacetedFilter } from '@/shared';
|
||||
|
||||
import { getFieldColumns } from '../field-columns';
|
||||
import type { FieldInfo } from '../field.type';
|
||||
|
||||
interface IProps {
|
||||
fields: FieldInfo[];
|
||||
onClickRow?: (index: number, field: FieldInfo) => void;
|
||||
reorder?: (data: FieldInfo[]) => void;
|
||||
disableFilter?: boolean;
|
||||
}
|
||||
|
||||
const FieldTable: React.FC<IProps> = (props) => {
|
||||
const { fields, onClickRow, reorder, disableFilter } = props;
|
||||
|
||||
const columns = useMemo(() => getFieldColumns(reorder), []);
|
||||
|
||||
const table = useReactTable({
|
||||
columns,
|
||||
data: fields.sort((a, b) => a.order - b.order),
|
||||
enableColumnFilters: true,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getRowId: (row) => String(row.key),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-4">
|
||||
{!disableFilter && (
|
||||
<div className="flex gap-3">
|
||||
<TableFacetedFilter
|
||||
column={table.getColumn('status')}
|
||||
options={[
|
||||
{ label: 'Inactive', value: 'INACTIVE' },
|
||||
{ label: 'Active', value: 'ACTIVE' },
|
||||
]}
|
||||
title="Status"
|
||||
/>
|
||||
<TableFacetedFilter
|
||||
column={table.getColumn('property')}
|
||||
options={[
|
||||
{ label: 'Editable', value: 'EDITABLE' },
|
||||
{ label: 'Read Only', value: 'READ_ONLY' },
|
||||
]}
|
||||
title="Property"
|
||||
/>
|
||||
{table.getState().columnFilters.length > 0 && (
|
||||
<Button variant="ghost" onClick={() => table.resetColumnFilters()}>
|
||||
<Icon name="RiCloseLine" />
|
||||
Reset
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<BasicTable
|
||||
table={table}
|
||||
onClickRow={(index, row) => onClickRow?.(index, row)}
|
||||
reorder={(data) =>
|
||||
reorder?.(
|
||||
data.map((field, index) => ({ ...field, order: index + 1 })),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FieldTable;
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export { default as FieldTable } from './field-table.ui';
|
||||
export { default as FieldSettingSheet } from './field-setting-sheet.ui';
|
||||
export { default as FeedbackRequestCodePopover } from './feedback-request-code-popover.ui';
|
||||
export { default as PreviewFieldTable } from './preview-field-table.ui';
|
||||
export { default as FieldFormatLabel } from './field-format-label.ui';
|
||||
export { default as AiFieldOptionPopover } from './ai-field-option-popover.ui';
|
||||
export { default as SelectOptionListPopover } from './select-option-list-popover.ui';
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { memo, useMemo } from 'react';
|
||||
import { getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
||||
|
||||
import { BasicTable } from '@/shared';
|
||||
import { usePreviewFeedback } from '@/entities/feedback';
|
||||
import { getColumns } from '@/entities/feedback/feedback-table-columns';
|
||||
|
||||
import type { FieldInfo } from '../field.type';
|
||||
|
||||
interface IProps {
|
||||
fields: FieldInfo[];
|
||||
}
|
||||
|
||||
const PreviewFieldTable: React.FC<IProps> = ({ fields }) => {
|
||||
const columns = useMemo(
|
||||
() => getColumns(fields, true).filter((v) => v.id !== 'feedback-checkbox'),
|
||||
[fields],
|
||||
);
|
||||
const feedbacks = usePreviewFeedback(fields);
|
||||
|
||||
const table = useReactTable({
|
||||
columns,
|
||||
data: feedbacks,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getRowId: (row) => String(row.id),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto">
|
||||
<BasicTable table={table} className="table-fixed" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(
|
||||
PreviewFieldTable,
|
||||
(prev, next) => JSON.stringify(prev.fields) === JSON.stringify(next.fields),
|
||||
);
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownContent,
|
||||
DropdownItem,
|
||||
DropdownTrigger,
|
||||
Icon,
|
||||
Tag,
|
||||
} from '@ufb/react';
|
||||
|
||||
import type { FieldOptionInfo } from '../field.type';
|
||||
|
||||
interface IProps {
|
||||
options: FieldOptionInfo[];
|
||||
}
|
||||
|
||||
const SelectOptionListPopover: React.FC<IProps> = ({ options }) => {
|
||||
return (
|
||||
<Dropdown>
|
||||
<DropdownTrigger asChild data-state="close">
|
||||
<Tag onClick={(e) => e.stopPropagation()}>
|
||||
Select Options
|
||||
<Icon name="RiInformation2Line" />
|
||||
</Tag>
|
||||
</DropdownTrigger>
|
||||
<DropdownContent>
|
||||
{options.map((v) => (
|
||||
<DropdownItem key={v.key}>{v.name}</DropdownItem>
|
||||
))}
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectOptionListPopover;
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export * from './ui';
|
||||
export * from './issue-tracker.type';
|
||||
export * from './issue-tracker.schema';
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
|
||||
export const issueTrackerSchema = z.object({
|
||||
ticketDomain: z.string().trim().url().nullable().or(z.literal('')),
|
||||
ticketKey: z.string().trim().nullable(),
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { z } from 'zod';
|
||||
|
||||
import type { issueTrackerSchema } from './issue-tracker.schema';
|
||||
|
||||
export type IssueTracker = z.infer<typeof issueTrackerSchema>;
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export { default as IssueTrackerForm } from './issue-tracker-form.ui';
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
|
||||
import { Icon, InputField, Label } from '@ufb/react';
|
||||
|
||||
import { cn, SelectInput, TextInput } from '@/shared';
|
||||
|
||||
import type { IssueTracker } from '../issue-tracker.type';
|
||||
|
||||
interface IProps {
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
const IssueTrackerForm: React.FC<IProps> = ({ readOnly }) => {
|
||||
const { register, watch, formState } = useFormContext<IssueTracker>();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<SelectInput
|
||||
options={[{ value: 'jira', label: 'Jira' }]}
|
||||
value="jira"
|
||||
label="Issue Tracking System"
|
||||
disabled={readOnly}
|
||||
/>
|
||||
<TextInput
|
||||
label="Base URL"
|
||||
placeholder="https://example.com"
|
||||
error={formState.errors.ticketDomain?.message}
|
||||
{...register('ticketDomain')}
|
||||
/>
|
||||
<TextInput
|
||||
label="Project Key"
|
||||
placeholder="PROJ"
|
||||
error={formState.errors.ticketKey?.message}
|
||||
disabled={readOnly}
|
||||
{...register('ticketKey')}
|
||||
/>
|
||||
<InputField>
|
||||
<Label>Preview</Label>
|
||||
<div
|
||||
className={cn(
|
||||
'bg-neutral-tertiary flex items-center gap-2 rounded p-4',
|
||||
{ 'opacity-50': !watch('ticketDomain') || !watch('ticketKey') },
|
||||
)}
|
||||
>
|
||||
<Icon name="RiPriceTag3Fill" size={16} />
|
||||
{/* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing */}
|
||||
{`${watch('ticketDomain') || 'https://example.com'}/browse/${watch('ticketKey') || 'PROJ'}-{Number}`}
|
||||
</div>
|
||||
</InputField>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IssueTrackerForm;
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export * from './issue.type';
|
||||
export * from './ui';
|
||||
export * from './lib';
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { categorySchema } from '../category/category.schema';
|
||||
|
||||
export const issueSchema = z.object({
|
||||
id: z.number(),
|
||||
name: z.string().trim().min(1).max(30),
|
||||
description: z.string().trim().max(50).nullable(),
|
||||
feedbackCount: z.number(),
|
||||
status: z.union([
|
||||
z.literal('INIT'),
|
||||
z.literal('ON_REVIEW'),
|
||||
z.literal('IN_PROGRESS'),
|
||||
z.literal('RESOLVED'),
|
||||
z.literal('PENDING'),
|
||||
]),
|
||||
externalIssueId: z.string().trim().optional(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string().nullable(),
|
||||
category: categorySchema.nullable(),
|
||||
});
|
||||
|
||||
export const issueFormSchema = issueSchema.pick({
|
||||
name: true,
|
||||
description: true,
|
||||
status: true,
|
||||
externalIssueId: true,
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { z } from 'zod';
|
||||
|
||||
import type { issueFormSchema, issueSchema } from './issue.schema';
|
||||
|
||||
export type Issue = z.infer<typeof issueSchema>;
|
||||
export type IssueStatus = Issue['status'];
|
||||
export type IssueFormSchema = z.infer<typeof issueFormSchema>;
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export { default as useIssueSearch } from './use-issue-search';
|
||||
export { default as useIssueSearchInfinite } from './use-issue-search-infinite';
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
|
||||
import type {
|
||||
OAIMutationResponse,
|
||||
OAIRequestBody,
|
||||
SearchQuery,
|
||||
} from '@/shared';
|
||||
import { client } from '@/shared';
|
||||
|
||||
type TData = OAIMutationResponse<
|
||||
'/api/admin/projects/{projectId}/issues/search',
|
||||
'post'
|
||||
>;
|
||||
|
||||
interface IBody
|
||||
extends Omit<
|
||||
OAIRequestBody<'/api/admin/projects/{projectId}/issues/search', 'post'>,
|
||||
'queries'
|
||||
> {
|
||||
queries: SearchQuery[];
|
||||
}
|
||||
|
||||
const useIssueSearchInfinite = (
|
||||
projectId: number,
|
||||
body: IBody = { limit: 20, page: 1, queries: [], sort: {} },
|
||||
) => {
|
||||
return useInfiniteQuery<TData>({
|
||||
queryKey: [
|
||||
'/api/admin/projects/{projectId}/issues/search',
|
||||
projectId,
|
||||
body,
|
||||
],
|
||||
queryFn: async ({ pageParam }) => {
|
||||
const { data: result } = await client.post({
|
||||
path: '/api/admin/projects/{projectId}/issues/search',
|
||||
pathParams: { projectId },
|
||||
body: { ...body, page: pageParam as number },
|
||||
});
|
||||
return result;
|
||||
},
|
||||
getNextPageParam: (lastPage) => {
|
||||
if (!lastPage) return undefined;
|
||||
if (lastPage.meta.currentPage < lastPage.meta.totalPages) {
|
||||
return lastPage.meta.currentPage + 1;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
initialPageParam: 1,
|
||||
initialData: {
|
||||
pageParams: [],
|
||||
pages: [
|
||||
{
|
||||
items: [],
|
||||
meta: {
|
||||
currentPage: 1,
|
||||
totalPages: 0,
|
||||
totalItems: 0,
|
||||
itemCount: 0,
|
||||
itemsPerPage: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default useIssueSearchInfinite;
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { UseQueryOptions } from '@tanstack/react-query';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import type {
|
||||
OAIMutationResponse,
|
||||
OAIRequestBody,
|
||||
SearchQuery,
|
||||
} from '@/shared';
|
||||
import { client } from '@/shared';
|
||||
|
||||
type TData = OAIMutationResponse<
|
||||
'/api/admin/projects/{projectId}/issues/search',
|
||||
'post'
|
||||
>;
|
||||
|
||||
interface IBody
|
||||
extends Omit<
|
||||
OAIRequestBody<'/api/admin/projects/{projectId}/issues/search', 'post'>,
|
||||
'queries'
|
||||
> {
|
||||
queries: SearchQuery[];
|
||||
}
|
||||
|
||||
const useIssueSearch = (
|
||||
projectId: number,
|
||||
body: IBody = { limit: 10, page: 1, queries: [], sort: {} },
|
||||
options?: Omit<UseQueryOptions<TData>, 'queryKey' | 'queryFn'>,
|
||||
) => {
|
||||
return useQuery<TData>({
|
||||
queryKey: [
|
||||
'/api/admin/projects/{projectId}/issues/search',
|
||||
projectId,
|
||||
body,
|
||||
],
|
||||
queryFn: async () => {
|
||||
const { data: result } = await client.post({
|
||||
path: '/api/admin/projects/{projectId}/issues/search',
|
||||
pathParams: { projectId },
|
||||
body,
|
||||
});
|
||||
return result;
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export default useIssueSearch;
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export { default as IssueBadge } from './issue-badge.ui';
|
||||
export { default as IssueSelectBox } from './issue-select-box.ui';
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import type { BadgeProps } from '@ufb/react';
|
||||
import { Badge } from '@ufb/react';
|
||||
|
||||
import { ISSUES } from '@/shared';
|
||||
import { BADGE_COLOR_MAP } from '@/shared/constants/color-map';
|
||||
import type { BadgeColor } from '@/shared/constants/color-map';
|
||||
|
||||
import type { Issue, IssueStatus } from '../issue.type';
|
||||
|
||||
const ISSUE_COLOR_MAP: Record<IssueStatus, BadgeColor> = {
|
||||
INIT: 'yellow',
|
||||
ON_REVIEW: 'green',
|
||||
IN_PROGRESS: 'sky',
|
||||
RESOLVED: 'zinc',
|
||||
PENDING: 'indigo',
|
||||
};
|
||||
interface IProps extends Omit<BadgeProps, 'color'> {
|
||||
right?: React.ReactNode;
|
||||
status: Issue['status'];
|
||||
name?: string;
|
||||
}
|
||||
|
||||
const IssueBadge: React.FC<IProps> = ({ name, status, right, ...props }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Badge {...props} className={BADGE_COLOR_MAP[ISSUE_COLOR_MAP[status]]}>
|
||||
{name ?? ISSUES(t).find((v) => v.key === status)?.name} {right}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
export default IssueBadge;
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useThrottle } from 'react-use';
|
||||
|
||||
import { client } from '@/shared';
|
||||
import AsyncMultiSelectSearchInput from '@/shared/ui/inputs/async-multi-select-search-input.ui';
|
||||
|
||||
import { useIssueSearchInfinite } from '../lib';
|
||||
|
||||
interface Props {
|
||||
onChange: (value?: number[]) => void;
|
||||
value?: number[];
|
||||
}
|
||||
|
||||
const IssueSelectBox = ({ onChange, value }: Props) => {
|
||||
const router = useRouter();
|
||||
const projectId = Number(router.query.projectId as string);
|
||||
const [selectedIssues, setSelectedIssues] = useState<
|
||||
{ label: string; value: string }[]
|
||||
>([]);
|
||||
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const throttedValue = useThrottle(inputValue, 500);
|
||||
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
|
||||
useIssueSearchInfinite(projectId, {
|
||||
queries: [{ key: 'name', value: throttedValue, condition: 'CONTAINS' }],
|
||||
sort: { name: 'ASC' },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
setSelectedIssues([]);
|
||||
return;
|
||||
}
|
||||
void Promise.allSettled(
|
||||
value.map(async (issueId) => {
|
||||
try {
|
||||
const { data } = await client.get({
|
||||
path: '/api/admin/projects/{projectId}/issues/{issueId}',
|
||||
pathParams: { projectId, issueId },
|
||||
});
|
||||
return { label: data.name, value: String(issueId) };
|
||||
} catch (error) {
|
||||
console.warn(`Failed to fetch issue ${issueId}:`, error);
|
||||
return { label: `Issue ${issueId}`, value: String(issueId) };
|
||||
}
|
||||
}),
|
||||
).then((results) => {
|
||||
const successfulResults = results
|
||||
.filter((result) => result.status === 'fulfilled')
|
||||
.map((result) => result.value);
|
||||
setSelectedIssues(successfulResults);
|
||||
});
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<AsyncMultiSelectSearchInput
|
||||
options={data.pages.flatMap((page) =>
|
||||
page ?
|
||||
page.items.map(({ name, id }) => ({ label: name, value: String(id) }))
|
||||
: [],
|
||||
)}
|
||||
value={selectedIssues}
|
||||
onChange={(input) => onChange(input.map((v) => Number(v.value)))}
|
||||
fetchNextPage={fetchNextPage}
|
||||
hasNextPage={hasNextPage}
|
||||
inputValue={inputValue}
|
||||
setInputValue={setInputValue}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default IssueSelectBox;
|
||||
@@ -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
Reference in new issue
Block a user