This commit is contained in:
commit
33453ecc55
3475 files changed
+850363
No files matched your search
@@ -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';
|
||||
+20
@@ -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 { z } from 'zod';
|
||||
|
||||
export const requestResetPasswordWithEmailSchema = z.object({
|
||||
email: z.email(),
|
||||
});
|
||||
+43
@@ -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 resetPasswordWithEmailSchema = z
|
||||
.object({
|
||||
password: z.string().min(8),
|
||||
confirmPassword: z.string().min(8),
|
||||
code: z.string(),
|
||||
email: z.email(),
|
||||
})
|
||||
.refine(({ password, confirmPassword }) => password === confirmPassword, {
|
||||
message: 'must equal Password',
|
||||
path: ['confirmPassword'],
|
||||
})
|
||||
.refine(({ password }) => /[A-Za-z]/.test(password), {
|
||||
message: 'must contain at least one letter.',
|
||||
path: ['password'],
|
||||
})
|
||||
.refine(
|
||||
({ password }) => /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?`~]/.test(password),
|
||||
{
|
||||
message: 'must contain at least one special character.',
|
||||
path: ['password'],
|
||||
},
|
||||
)
|
||||
.refine(({ password }) => !/(.)\1/.test(password), {
|
||||
message: 'must not contain consecutive identical characters',
|
||||
path: ['password'],
|
||||
});
|
||||
@@ -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 RequestResetPasswordWithEmail } from './request-reset-password-with-email.ui';
|
||||
export { default as ResetPasswordWithEmailForm } from './reset-password-with-email-form.ui';
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES 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 { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { Button, toast } from '@ufb/react';
|
||||
|
||||
import { Path, TextInput, useOAIMutation } from '@/shared';
|
||||
|
||||
import { requestResetPasswordWithEmailSchema } from '../request-reset-password-with-email.schema';
|
||||
|
||||
type FormType = z.infer<typeof requestResetPasswordWithEmailSchema>;
|
||||
|
||||
interface IProps {}
|
||||
|
||||
const RequestResetPasswordWithEmail: React.FC<IProps> = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const { register, handleSubmit, formState } = useForm<FormType>({
|
||||
resolver: zodResolver(requestResetPasswordWithEmailSchema),
|
||||
});
|
||||
|
||||
const { mutate, isPending } = useOAIMutation({
|
||||
method: 'post',
|
||||
path: '/api/admin/users/password/reset/code',
|
||||
queryOptions: {
|
||||
async onSuccess() {
|
||||
await router.push(Path.SIGN_IN);
|
||||
toast.success(t('v2.toast.success'));
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit((data) => mutate(data))}>
|
||||
<div className="mb-12 flex flex-col gap-4">
|
||||
<TextInput
|
||||
type="email"
|
||||
label="Email"
|
||||
placeholder={t('v2.placeholder.text')}
|
||||
error={formState.errors.email?.message}
|
||||
{...register('email')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
size="medium"
|
||||
type="submit"
|
||||
loading={isPending}
|
||||
disabled={!formState.isValid}
|
||||
>
|
||||
{t('v2.auth.reset-password.button.send-email')}
|
||||
</Button>
|
||||
<Button
|
||||
size="medium"
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={router.back}
|
||||
>
|
||||
{t('button.back')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default RequestResetPasswordWithEmail;
|
||||
+95
@@ -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 { useRouter } from 'next/router';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { Button, toast } from '@ufb/react';
|
||||
|
||||
import { Path, TextInput, useOAIMutation } from '@/shared';
|
||||
|
||||
import { resetPasswordWithEmailSchema } from '../reset-password-with-email.schema';
|
||||
|
||||
type FormType = z.infer<typeof resetPasswordWithEmailSchema>;
|
||||
|
||||
interface IProps {
|
||||
code: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
const ResetPasswordWithEmailForm: React.FC<IProps> = ({ code, email }) => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
const { handleSubmit, register, formState } = useForm<FormType>({
|
||||
resolver: zodResolver(resetPasswordWithEmailSchema),
|
||||
defaultValues: { code, email },
|
||||
});
|
||||
|
||||
const { mutate, isPending } = useOAIMutation({
|
||||
method: 'post',
|
||||
path: '/api/admin/users/password/reset',
|
||||
queryOptions: {
|
||||
async onSuccess() {
|
||||
toast.success(t('v2.toast.success'));
|
||||
await router.push(Path.SIGN_IN);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = ({ password }: FormType) =>
|
||||
mutate({ code, email, password });
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="mb-10 flex flex-col gap-4">
|
||||
<TextInput
|
||||
type="email"
|
||||
label="Email"
|
||||
placeholder={t('v2.placeholder.text')}
|
||||
{...register('email')}
|
||||
disabled
|
||||
/>
|
||||
<TextInput
|
||||
type="password"
|
||||
label="Password"
|
||||
placeholder={t('v2.placeholder.text')}
|
||||
error={formState.errors.password?.message}
|
||||
{...register('password')}
|
||||
/>
|
||||
<TextInput
|
||||
type="password"
|
||||
label="Confirm Password"
|
||||
placeholder={t('v2.placeholder.text')}
|
||||
error={formState.errors.confirmPassword?.message}
|
||||
{...register('confirmPassword')}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={isPending}
|
||||
disabled={!formState.isDirty}
|
||||
size="medium"
|
||||
>
|
||||
{t('v2.button.confirm')}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResetPasswordWithEmailForm;
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export * from './ui';
|
||||
export * from './lib';
|
||||
@@ -0,0 +1,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 { useOAuthCallback } from './use-oauth-callback';
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES 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 { useRouter } from 'next/router';
|
||||
import { AxiosError } from 'axios';
|
||||
|
||||
import { toast } from '@ufb/react';
|
||||
|
||||
import type { IFetchError } from '@/shared';
|
||||
import { useUserStore } from '@/entities/user';
|
||||
|
||||
export const useOAuthCallback = () => {
|
||||
const router = useRouter();
|
||||
|
||||
const { signInWithOAuth } = useUserStore();
|
||||
const [status, setStatus] = useState<'loading' | 'error'>('loading');
|
||||
const processedCodeRef = useRef<string | null>(null);
|
||||
|
||||
const code = router.query.code as string | undefined;
|
||||
const error = router.query.error as string | undefined;
|
||||
const errorDescription = router.query.error_description as string | undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
toast.error(
|
||||
errorDescription ?? 'An error occurred during OAuth2.0 login. Please contact the administrator.',
|
||||
);
|
||||
setStatus('error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Next.js renders once before the query string is available. Do not leave
|
||||
// the callback in an ambiguous state while the router is hydrating.
|
||||
if (!router.isReady || !code || processedCodeRef.current === code) return;
|
||||
|
||||
// OAuth authorization codes are single-use. React StrictMode can run
|
||||
// effects twice during local development, so exchange each code once.
|
||||
processedCodeRef.current = code;
|
||||
|
||||
void signInWithOAuth({ code }).catch((error) => {
|
||||
if (error instanceof AxiosError && error.response) {
|
||||
const message = error.response.data as IFetchError;
|
||||
toast.error(
|
||||
message.message ??
|
||||
message.axiosError?.error ??
|
||||
'An error occurred during OAuth2.0 login. Please contact the administrator.',
|
||||
);
|
||||
} else {
|
||||
toast.error(
|
||||
'An error occurred during OAuth2.0 login. Please contact the administrator.',
|
||||
);
|
||||
}
|
||||
setStatus('error');
|
||||
});
|
||||
}, [code, error, errorDescription, router.isReady, signInWithOAuth]);
|
||||
|
||||
return { status };
|
||||
};
|
||||
@@ -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 SignInWithOAuthButton } from './sign-in-with-oauth-button.ui';
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES 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 Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { Button } from '@ufb/react';
|
||||
|
||||
import { useOAIQuery } from '@/shared';
|
||||
import { useTenantStore } from '@/entities/tenant';
|
||||
|
||||
interface IProps {}
|
||||
|
||||
const OAUTH_CALLBACK_URL_STORAGE_KEY = 'ufb.oauth.callback-url';
|
||||
const OAUTH_FORCE_LOGIN_STORAGE_KEY = 'ufb.oauth.force-login';
|
||||
|
||||
const SignInWithOAuthButton: React.FC<IProps> = () => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const { tenant } = useTenantStore();
|
||||
|
||||
const callback_url = (router.query.callback_url ?? '') as string;
|
||||
const force_login =
|
||||
typeof window !== 'undefined' ?
|
||||
(router.query.force_login ??
|
||||
window.sessionStorage.getItem(OAUTH_FORCE_LOGIN_STORAGE_KEY) ??
|
||||
'')
|
||||
: '';
|
||||
|
||||
const { data } = useOAIQuery({
|
||||
path: '/api/admin/auth/signIn/oauth/loginURL',
|
||||
queryOptions: { enabled: tenant?.useOAuth ?? false },
|
||||
variables: {
|
||||
callback_url,
|
||||
force_login: force_login ? 'true' : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!force_login || !data?.url || typeof window === 'undefined') return;
|
||||
|
||||
if (callback_url) {
|
||||
window.sessionStorage.setItem(
|
||||
OAUTH_CALLBACK_URL_STORAGE_KEY,
|
||||
callback_url,
|
||||
);
|
||||
} else {
|
||||
window.sessionStorage.removeItem(OAUTH_CALLBACK_URL_STORAGE_KEY);
|
||||
}
|
||||
|
||||
window.sessionStorage.setItem(OAUTH_FORCE_LOGIN_STORAGE_KEY, 'true');
|
||||
window.location.assign(data.url);
|
||||
}, [callback_url, data?.url, force_login]);
|
||||
|
||||
if (tenant?.oauthConfig?.loginButtonType === 'GOOGLE') {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="medium"
|
||||
disabled={!data?.url}
|
||||
onClick={() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
if (callback_url) {
|
||||
window.sessionStorage.setItem(
|
||||
OAUTH_CALLBACK_URL_STORAGE_KEY,
|
||||
callback_url,
|
||||
);
|
||||
} else {
|
||||
window.sessionStorage.removeItem(OAUTH_CALLBACK_URL_STORAGE_KEY);
|
||||
}
|
||||
if (force_login) {
|
||||
window.sessionStorage.setItem(
|
||||
OAUTH_FORCE_LOGIN_STORAGE_KEY,
|
||||
'true',
|
||||
);
|
||||
} else {
|
||||
window.sessionStorage.removeItem(OAUTH_FORCE_LOGIN_STORAGE_KEY);
|
||||
}
|
||||
window.location.assign(data?.url ?? '');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src="/assets/images/google.svg"
|
||||
alt="Google"
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
Google {t('button.sign-in')}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="medium"
|
||||
disabled={!data?.url}
|
||||
onClick={() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
if (callback_url) {
|
||||
window.sessionStorage.setItem(
|
||||
OAUTH_CALLBACK_URL_STORAGE_KEY,
|
||||
callback_url,
|
||||
);
|
||||
} else {
|
||||
window.sessionStorage.removeItem(OAUTH_CALLBACK_URL_STORAGE_KEY);
|
||||
}
|
||||
if (force_login) {
|
||||
window.sessionStorage.setItem(
|
||||
OAUTH_FORCE_LOGIN_STORAGE_KEY,
|
||||
'true',
|
||||
);
|
||||
} else {
|
||||
window.sessionStorage.removeItem(OAUTH_FORCE_LOGIN_STORAGE_KEY);
|
||||
}
|
||||
window.location.assign(data?.url ?? '');
|
||||
}
|
||||
}}
|
||||
>
|
||||
{tenant?.oauthConfig?.loginButtonName ??
|
||||
`OAuth 2.0 ${t('button.sign-in')}`}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default SignInWithOAuthButton;
|
||||
@@ -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 type { SignUpWithEmailType } from './sign-up-with-email.type';
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES 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 signUpWithEmailSchema = z
|
||||
.object({
|
||||
email: z.email(),
|
||||
emailState: z.enum(['NOT_VERIFIED', 'VERIFING', 'EXPIRED', 'VERIFIED']),
|
||||
code: z.string().length(6),
|
||||
password: z.string().min(8),
|
||||
confirmPassword: z.string().min(8),
|
||||
})
|
||||
.refine((schema) => schema.password === schema.confirmPassword, {
|
||||
message: 'must equal Password',
|
||||
path: ['confirmPassword'],
|
||||
})
|
||||
.refine(({ password }) => /[A-Za-z]/.test(password), {
|
||||
message: 'must contain at least one letter.',
|
||||
path: ['password'],
|
||||
})
|
||||
.refine(
|
||||
({ password }) => /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?`~]/.test(password),
|
||||
{
|
||||
message: 'must contain at least one special character.',
|
||||
path: ['password'],
|
||||
},
|
||||
)
|
||||
.refine(({ password }) => !/(.)\1/.test(password), {
|
||||
message: 'must not contain consecutive identical characters',
|
||||
path: ['password'],
|
||||
});
|
||||
@@ -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 { signUpWithEmailSchema } from './sign-up-with-email.schema';
|
||||
|
||||
export type SignUpWithEmailType = z.infer<typeof signUpWithEmailSchema>;
|
||||
@@ -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 SignUpWithEmailForm } from './sign-up-with-email-form.ui';
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES 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 { zodResolver } from '@hookform/resolvers/zod';
|
||||
import dayjs from 'dayjs';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useInterval } from 'react-use';
|
||||
|
||||
import { Button } from '@ufb/react';
|
||||
|
||||
import { TextInput, useOAIMutation } from '@/shared';
|
||||
|
||||
import { signUpWithEmailSchema } from '../sign-up-with-email.schema';
|
||||
import type { SignUpWithEmailType } from '../sign-up-with-email.type';
|
||||
|
||||
interface IProps {
|
||||
onSubmit: (data: SignUpWithEmailType) => void;
|
||||
loading?: boolean;
|
||||
submitText: string;
|
||||
initialValues?: SignUpWithEmailType | null;
|
||||
skipEmailVerification?: boolean;
|
||||
}
|
||||
|
||||
const SignUpWithEmailForm: React.FC<IProps> = (props) => {
|
||||
const {
|
||||
onSubmit,
|
||||
loading,
|
||||
submitText,
|
||||
initialValues,
|
||||
skipEmailVerification,
|
||||
} = props;
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
formState,
|
||||
getValues,
|
||||
setValue,
|
||||
watch,
|
||||
setError,
|
||||
clearErrors,
|
||||
} = useForm<SignUpWithEmailType>({
|
||||
resolver: zodResolver(signUpWithEmailSchema),
|
||||
defaultValues:
|
||||
initialValues ?
|
||||
{ ...initialValues }
|
||||
: {
|
||||
emailState: skipEmailVerification ? 'VERIFIED' : 'NOT_VERIFIED',
|
||||
code: skipEmailVerification ? '000000' : '',
|
||||
},
|
||||
});
|
||||
|
||||
const [expiredTime, setExpiredTime] = useState<string>();
|
||||
const [leftTime, setLeftTime] = useState('');
|
||||
|
||||
const { mutate: fetchCode, status: fetchCodeStatus } = useOAIMutation({
|
||||
method: 'post',
|
||||
path: '/api/admin/auth/email/code',
|
||||
queryOptions: {
|
||||
onSuccess(data) {
|
||||
setValue('emailState', 'VERIFING');
|
||||
setExpiredTime(data?.expiredAt);
|
||||
clearErrors('email');
|
||||
},
|
||||
onError(error) {
|
||||
const { message } = error;
|
||||
setError('email', { message });
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: verifyCode, status: verifyCodeStatus } = useOAIMutation({
|
||||
method: 'post',
|
||||
path: '/api/admin/auth/email/code/verify',
|
||||
queryOptions: {
|
||||
onSuccess() {
|
||||
setValue('emailState', 'VERIFIED');
|
||||
clearErrors('code');
|
||||
},
|
||||
onError(error) {
|
||||
const { message } = error;
|
||||
setError('code', { message });
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
useInterval(
|
||||
() => {
|
||||
const seconds = dayjs(expiredTime).diff(dayjs(), 'seconds');
|
||||
|
||||
if (seconds < 0) {
|
||||
setLeftTime(`00:00`);
|
||||
setValue('emailState', 'EXPIRED');
|
||||
} else {
|
||||
const m = Math.floor(seconds / 60)
|
||||
.toString()
|
||||
.padStart(2, '0');
|
||||
const s = Math.floor(seconds % 60)
|
||||
.toString()
|
||||
.padStart(2, '0');
|
||||
|
||||
setLeftTime(`${m}:${s}`);
|
||||
}
|
||||
},
|
||||
watch('emailState') === 'VERIFING' ? 1000 : null,
|
||||
);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit((data) => onSubmit(data))}>
|
||||
<div className="mb-12 flex flex-col gap-4">
|
||||
<div className="flex gap-2">
|
||||
<TextInput
|
||||
type="email"
|
||||
{...register('email')}
|
||||
label="Email"
|
||||
placeholder={t('v2.placeholder.text')}
|
||||
disabled={
|
||||
!skipEmailVerification && watch('emailState') !== 'NOT_VERIFIED'
|
||||
}
|
||||
error={formState.errors.email?.message}
|
||||
infoCaption={
|
||||
!skipEmailVerification && watch('emailState') !== 'NOT_VERIFIED' ?
|
||||
t('v2.text.request-email-auth')
|
||||
: undefined
|
||||
}
|
||||
rightButton={
|
||||
skipEmailVerification ? undefined : (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => fetchCode({ email: getValues('email') })}
|
||||
loading={fetchCodeStatus === 'pending'}
|
||||
disabled={watch('emailState') === 'VERIFIED'}
|
||||
className="min-w-[96px]"
|
||||
>
|
||||
{t('v2.auth.sign-up.button.request-auth-code')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{!skipEmailVerification && watch('emailState') !== 'NOT_VERIFIED' && (
|
||||
<div className="flex gap-2">
|
||||
<TextInput
|
||||
type="text"
|
||||
label="Authentication code"
|
||||
{...register('code')}
|
||||
disabled={watch('emailState') === 'VERIFIED'}
|
||||
placeholder={t('v2.placeholder.text')}
|
||||
error={formState.errors.code?.message}
|
||||
right={
|
||||
(watch('emailState') === 'VERIFING' ||
|
||||
watch('emailState') === 'EXPIRED') && (
|
||||
<p className="font-16-regular">{leftTime}</p>
|
||||
)
|
||||
}
|
||||
rightButton={
|
||||
<Button
|
||||
loading={verifyCodeStatus === 'pending'}
|
||||
disabled={
|
||||
watch('emailState') === 'VERIFIED' ||
|
||||
watch('code').length !== 6
|
||||
}
|
||||
onClick={() => {
|
||||
const code = getValues('code');
|
||||
if (!code) return;
|
||||
verifyCode({ code, email: getValues('email') });
|
||||
}}
|
||||
className="min-w-[96px]"
|
||||
>
|
||||
{t('v2.auth.sign-up.button.verify-auth-code')}
|
||||
</Button>
|
||||
}
|
||||
successCaption={
|
||||
watch('emailState') === 'VERIFIED' ? 'Authenticated.' : (
|
||||
undefined
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<TextInput
|
||||
type="password"
|
||||
label="Password"
|
||||
placeholder={t('v2.placeholder.text')}
|
||||
error={formState.errors.password?.message}
|
||||
{...register('password')}
|
||||
/>
|
||||
<TextInput
|
||||
type="password"
|
||||
label="Confirm Password"
|
||||
placeholder={t('v2.placeholder.text')}
|
||||
error={formState.errors.confirmPassword?.message}
|
||||
{...register('confirmPassword')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Button
|
||||
size="medium"
|
||||
type="submit"
|
||||
loading={loading}
|
||||
disabled={!formState.isDirty || watch('emailState') !== 'VERIFIED'}
|
||||
>
|
||||
{submitText}
|
||||
</Button>
|
||||
<Button size="medium" variant="outline" onClick={router.back}>
|
||||
{t('button.back')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default SignUpWithEmailForm;
|
||||
@@ -0,0 +1,369 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES 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';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
import type { ChannelInfo } from '@/entities/channel';
|
||||
import type { FieldInfo } from '@/entities/field';
|
||||
|
||||
import type { CreateChannelStepKey } from './create-channel-type';
|
||||
import {
|
||||
CREATE_CHANNEL_MAIN_STEP_LIST,
|
||||
CREATE_CHANNEL_STEP_MAP,
|
||||
FIRST_CREATE_CHANNEL_STEP,
|
||||
LAST_CREATE_CHANNEL_STEP,
|
||||
} from './create-channel-type';
|
||||
|
||||
const DEFAULT_FIELDS: FieldInfo[] = [
|
||||
{
|
||||
format: 'number',
|
||||
property: 'READ_ONLY',
|
||||
status: 'ACTIVE',
|
||||
name: 'ID',
|
||||
key: 'id',
|
||||
description: '',
|
||||
order: 0,
|
||||
aiFieldTargetKeys: null,
|
||||
aiFieldTemplateId: null,
|
||||
aiFieldAutoProcessing: null,
|
||||
},
|
||||
{
|
||||
format: 'date',
|
||||
property: 'READ_ONLY',
|
||||
status: 'ACTIVE',
|
||||
name: 'Created',
|
||||
key: 'createdAt',
|
||||
description: '',
|
||||
order: 1,
|
||||
aiFieldTargetKeys: null,
|
||||
aiFieldTemplateId: null,
|
||||
aiFieldAutoProcessing: null,
|
||||
},
|
||||
{
|
||||
format: 'date',
|
||||
property: 'READ_ONLY',
|
||||
status: 'ACTIVE',
|
||||
name: 'Updated',
|
||||
key: 'updatedAt',
|
||||
description: '',
|
||||
order: 2,
|
||||
aiFieldTargetKeys: null,
|
||||
aiFieldTemplateId: null,
|
||||
aiFieldAutoProcessing: null,
|
||||
},
|
||||
{
|
||||
format: 'multiSelect',
|
||||
property: 'EDITABLE',
|
||||
status: 'ACTIVE',
|
||||
name: 'Issue',
|
||||
key: 'issues',
|
||||
description: '',
|
||||
options: [],
|
||||
order: 3,
|
||||
aiFieldTargetKeys: null,
|
||||
aiFieldTemplateId: null,
|
||||
aiFieldAutoProcessing: null,
|
||||
},
|
||||
{
|
||||
format: 'text',
|
||||
property: 'EDITABLE',
|
||||
status: 'ACTIVE',
|
||||
name: 'title',
|
||||
key: 'title',
|
||||
description: '제목',
|
||||
order: 4,
|
||||
aiFieldTargetKeys: null,
|
||||
aiFieldTemplateId: null,
|
||||
aiFieldAutoProcessing: null,
|
||||
},
|
||||
{
|
||||
format: 'text',
|
||||
property: 'EDITABLE',
|
||||
status: 'ACTIVE',
|
||||
name: 'contents',
|
||||
key: 'contents',
|
||||
description: '내용',
|
||||
order: 5,
|
||||
aiFieldTargetKeys: null,
|
||||
aiFieldTemplateId: null,
|
||||
aiFieldAutoProcessing: null,
|
||||
},
|
||||
{
|
||||
format: 'select',
|
||||
property: 'EDITABLE',
|
||||
status: 'ACTIVE',
|
||||
name: '중요도',
|
||||
key: 'priority',
|
||||
description: '관리자가 지정하는 피드백 중요도입니다.',
|
||||
options: [
|
||||
{ key: 'LOW', name: '낮음' },
|
||||
{ key: 'MEDIUM', name: '보통' },
|
||||
{ key: 'HIGH', name: '높음' },
|
||||
{ key: 'CRITICAL', name: '긴급' },
|
||||
],
|
||||
order: 6,
|
||||
aiFieldTargetKeys: null,
|
||||
aiFieldTemplateId: null,
|
||||
aiFieldAutoProcessing: null,
|
||||
},
|
||||
{
|
||||
format: 'text',
|
||||
property: 'EDITABLE',
|
||||
status: 'ACTIVE',
|
||||
name: 'IP',
|
||||
key: 'IP',
|
||||
description: '사용자 ip주소',
|
||||
order: 7,
|
||||
aiFieldTargetKeys: null,
|
||||
aiFieldTemplateId: null,
|
||||
aiFieldAutoProcessing: null,
|
||||
},
|
||||
{
|
||||
format: 'text',
|
||||
property: 'EDITABLE',
|
||||
status: 'ACTIVE',
|
||||
name: 'MAC_address',
|
||||
key: 'MAC_address',
|
||||
description: '사용자 MAC주소',
|
||||
order: 8,
|
||||
aiFieldTargetKeys: null,
|
||||
aiFieldTemplateId: null,
|
||||
aiFieldAutoProcessing: null,
|
||||
},
|
||||
{
|
||||
format: 'select',
|
||||
property: 'EDITABLE',
|
||||
status: 'ACTIVE',
|
||||
name: 'Category',
|
||||
key: 'Category',
|
||||
description: '문의 구분',
|
||||
options: [
|
||||
{ key: 'GENERAL_QNA', name: '일반문의' },
|
||||
{ key: 'ERROR_QNA', name: '오류문의' },
|
||||
{ key: 'IMPROVEMENT_QNA', name: '개선문의' },
|
||||
],
|
||||
order: 9,
|
||||
aiFieldTargetKeys: null,
|
||||
aiFieldTemplateId: null,
|
||||
aiFieldAutoProcessing: null,
|
||||
},
|
||||
{
|
||||
format: 'text',
|
||||
property: 'READ_ONLY',
|
||||
status: 'ACTIVE',
|
||||
name: '작성자',
|
||||
key: 'requester_id',
|
||||
description: '',
|
||||
order: 10,
|
||||
aiFieldTargetKeys: null,
|
||||
aiFieldTemplateId: null,
|
||||
aiFieldAutoProcessing: null,
|
||||
},
|
||||
{
|
||||
format: 'text',
|
||||
property: 'READ_ONLY',
|
||||
status: 'ACTIVE',
|
||||
name: '테넌트ID',
|
||||
key: 'requester_tenant_id',
|
||||
description: '',
|
||||
order: 11,
|
||||
aiFieldTargetKeys: null,
|
||||
aiFieldTemplateId: null,
|
||||
aiFieldAutoProcessing: null,
|
||||
},
|
||||
{
|
||||
format: 'text',
|
||||
property: 'READ_ONLY',
|
||||
status: 'ACTIVE',
|
||||
name: 'e-mail',
|
||||
key: 'requester_email',
|
||||
description: '',
|
||||
order: 12,
|
||||
aiFieldTargetKeys: null,
|
||||
aiFieldTemplateId: null,
|
||||
aiFieldAutoProcessing: null,
|
||||
},
|
||||
{
|
||||
format: 'text',
|
||||
property: 'READ_ONLY',
|
||||
status: 'ACTIVE',
|
||||
name: '이름',
|
||||
key: 'requester_name',
|
||||
description: '',
|
||||
order: 13,
|
||||
aiFieldTargetKeys: null,
|
||||
aiFieldTemplateId: null,
|
||||
aiFieldAutoProcessing: null,
|
||||
},
|
||||
{
|
||||
format: 'text',
|
||||
property: 'READ_ONLY',
|
||||
status: 'ACTIVE',
|
||||
name: '부서',
|
||||
key: 'requester_department',
|
||||
description: '',
|
||||
order: 14,
|
||||
aiFieldTargetKeys: null,
|
||||
aiFieldTemplateId: null,
|
||||
aiFieldAutoProcessing: null,
|
||||
},
|
||||
{
|
||||
format: 'text',
|
||||
property: 'READ_ONLY',
|
||||
status: 'ACTIVE',
|
||||
name: 'is_secret',
|
||||
key: 'is_secret',
|
||||
description: '',
|
||||
order: 15,
|
||||
aiFieldTargetKeys: null,
|
||||
aiFieldTemplateId: null,
|
||||
aiFieldAutoProcessing: null,
|
||||
},
|
||||
{
|
||||
format: 'select',
|
||||
property: 'EDITABLE',
|
||||
status: 'ACTIVE',
|
||||
name: '피드백 상태',
|
||||
key: 'feedback_status',
|
||||
description: '피드백 상태',
|
||||
options: [
|
||||
{ key: 'INIT', name: '신규' },
|
||||
{ key: 'ON_REVIEW', name: '검토중' },
|
||||
{ key: 'DETAILED_REVIEW', name: '정밀검토중' },
|
||||
{ key: 'IN_PROGRESS', name: '진행중' },
|
||||
{ key: 'RESOLVED', name: '완료' },
|
||||
{ key: 'PENDING', name: '진행하지 않음' },
|
||||
],
|
||||
order: 16,
|
||||
aiFieldTargetKeys: null,
|
||||
aiFieldTemplateId: null,
|
||||
aiFieldAutoProcessing: null,
|
||||
},
|
||||
{
|
||||
format: 'text',
|
||||
property: 'READ_ONLY',
|
||||
status: 'ACTIVE',
|
||||
name: '담당자',
|
||||
key: 'assignee',
|
||||
description: '관리자가 지정한 피드백 담당자입니다.',
|
||||
order: 17,
|
||||
aiFieldTargetKeys: null,
|
||||
aiFieldTemplateId: null,
|
||||
aiFieldAutoProcessing: null,
|
||||
},
|
||||
];
|
||||
|
||||
interface Input {
|
||||
channelInfo: ChannelInfo;
|
||||
fields: FieldInfo[];
|
||||
fieldPreview: null;
|
||||
}
|
||||
|
||||
interface Step {
|
||||
key: CreateChannelStepKey;
|
||||
index: number;
|
||||
}
|
||||
|
||||
interface State {
|
||||
editingStepIndex: number | null;
|
||||
currentStep: Step;
|
||||
input: Input;
|
||||
}
|
||||
|
||||
interface Action {
|
||||
jumpStepByIndex: (index: number) => void;
|
||||
jumpStepByKey: (key: CreateChannelStepKey) => void;
|
||||
prevStep: () => void;
|
||||
nextStep: () => void;
|
||||
reset: () => void;
|
||||
onChangeInput: <T extends keyof Input>(key: T, value: Input[T]) => void;
|
||||
setEditingStepIndex: (index: number) => void;
|
||||
}
|
||||
export const CREATE_CHANNEL_DEFAULT_INPUT: Input = {
|
||||
channelInfo: { name: '', description: '', feedbackSearchMaxDays: 365 },
|
||||
fields: DEFAULT_FIELDS,
|
||||
fieldPreview: null,
|
||||
};
|
||||
|
||||
const DEFAULT_STATE: State = {
|
||||
editingStepIndex: null,
|
||||
currentStep: {
|
||||
key: 'channel-info',
|
||||
index: 0,
|
||||
},
|
||||
input: {
|
||||
channelInfo: { name: '', description: '', feedbackSearchMaxDays: 365 },
|
||||
fields: DEFAULT_FIELDS,
|
||||
fieldPreview: null,
|
||||
},
|
||||
};
|
||||
|
||||
export const useCreateChannelStore = create<State & Action>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
...DEFAULT_STATE,
|
||||
setEditingStepIndex(index) {
|
||||
set({ editingStepIndex: index });
|
||||
},
|
||||
onChangeInput: <T extends keyof Input>(key: T, value: Input[T]) => {
|
||||
set(({ input }) => ({ input: { ...input, [key]: value } }));
|
||||
},
|
||||
jumpStepByIndex(index) {
|
||||
set({
|
||||
currentStep: {
|
||||
index,
|
||||
key: CREATE_CHANNEL_MAIN_STEP_LIST[index] ?? 'channel-info',
|
||||
},
|
||||
});
|
||||
},
|
||||
jumpStepByKey(key) {
|
||||
set({ currentStep: { index: CREATE_CHANNEL_STEP_MAP[key], key } });
|
||||
},
|
||||
nextStep() {
|
||||
set(({ currentStep }) => ({
|
||||
currentStep: {
|
||||
index: Math.min(currentStep.index + 1, LAST_CREATE_CHANNEL_STEP),
|
||||
key:
|
||||
CREATE_CHANNEL_MAIN_STEP_LIST[currentStep.index + 1] ??
|
||||
'channel-info',
|
||||
},
|
||||
editingStepIndex: Math.min(
|
||||
currentStep.index + 1,
|
||||
LAST_CREATE_CHANNEL_STEP,
|
||||
),
|
||||
}));
|
||||
},
|
||||
prevStep() {
|
||||
set(({ currentStep }) => ({
|
||||
currentStep: {
|
||||
index: Math.max(currentStep.index - 1, FIRST_CREATE_CHANNEL_STEP),
|
||||
key:
|
||||
CREATE_CHANNEL_MAIN_STEP_LIST[currentStep.index - 1] ??
|
||||
'channel-info',
|
||||
},
|
||||
}));
|
||||
},
|
||||
reset() {
|
||||
set({ ...DEFAULT_STATE });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'create-channel',
|
||||
version: 5,
|
||||
migrate: () => DEFAULT_STATE,
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* 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 const CREATE_CHANNEL_STEP_KEY_LIST = [
|
||||
'channel-info',
|
||||
'field',
|
||||
'field-preview',
|
||||
] as const;
|
||||
|
||||
export const CREATE_CHANNEL_MAIN_STEP_LIST: CreateChannelStepKey[] = [
|
||||
'channel-info',
|
||||
'field',
|
||||
];
|
||||
export const CREATE_CHANNEL_STEP_MAP: Record<CreateChannelStepKey, number> = {
|
||||
'channel-info': 0,
|
||||
field: 1,
|
||||
'field-preview': 1,
|
||||
};
|
||||
|
||||
export const LAST_CREATE_CHANNEL_STEP = CREATE_CHANNEL_STEP_KEY_LIST.length;
|
||||
export const FIRST_CREATE_CHANNEL_STEP = 0;
|
||||
|
||||
export type CreateChannelStepKey =
|
||||
(typeof CREATE_CHANNEL_STEP_KEY_LIST)[number];
|
||||
@@ -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 { Trans } from 'next-i18next';
|
||||
|
||||
import type { CreateChannelStepKey } from './create-channel-type';
|
||||
import InputChannelInfoStep from './ui/input-channel-info-step.ui';
|
||||
import InputFieldPreviewStep from './ui/input-field-preview-step.ui';
|
||||
import InputFieldStep from './ui/input-field-step.ui';
|
||||
|
||||
export const CREATE_CHANNEL_COMPONENTS: Record<
|
||||
CreateChannelStepKey,
|
||||
React.ReactNode
|
||||
> = {
|
||||
'channel-info': <InputChannelInfoStep />,
|
||||
field: <InputFieldStep />,
|
||||
'field-preview': <InputFieldPreviewStep />,
|
||||
};
|
||||
|
||||
export const CREATE_CHANNEL_STEPPER_TEXT: Record<
|
||||
CreateChannelStepKey,
|
||||
React.ReactNode
|
||||
> = {
|
||||
'channel-info': <Trans i18nKey="channel-setting-menu.channel-info" />,
|
||||
field: <Trans i18nKey="channel-setting-menu.field-mgmt" />,
|
||||
'field-preview': <Trans i18nKey="main.setting.field-mgmt.preview" />,
|
||||
};
|
||||
|
||||
export const CREATE_CHANNEL_HELP_TEXT: Record<
|
||||
CreateChannelStepKey,
|
||||
React.ReactNode
|
||||
> = {
|
||||
'channel-info': <Trans i18nKey="help-card.channel-info" />,
|
||||
field: <Trans i18nKey="help-card.field" />,
|
||||
'field-preview': <Trans i18nKey="help-card.field-preview" />,
|
||||
};
|
||||
export const CREATE_CHANNEL_REQUIRED_STEP: Record<
|
||||
CreateChannelStepKey,
|
||||
boolean
|
||||
> = {
|
||||
'channel-info': true,
|
||||
field: false,
|
||||
'field-preview': false,
|
||||
};
|
||||
@@ -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 './create-channel-model';
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES 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 { useOverlay } from '@toss/use-overlay';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
toast,
|
||||
} from '@ufb/react';
|
||||
import { ErrorCode } from '@ufb/shared';
|
||||
|
||||
import {
|
||||
CreateInputTemplate,
|
||||
isObjectEqual,
|
||||
Path,
|
||||
useAllChannels,
|
||||
useOAIMutation,
|
||||
useWarnIfSavedChanges,
|
||||
} from '@/shared';
|
||||
import { isDefaultField } from '@/entities/field';
|
||||
|
||||
import {
|
||||
CREATE_CHANNEL_DEFAULT_INPUT,
|
||||
useCreateChannelStore,
|
||||
} from '../create-channel-model';
|
||||
import { CREATE_CHANNEL_MAIN_STEP_LIST } from '../create-channel-type';
|
||||
import {
|
||||
CREATE_CHANNEL_HELP_TEXT,
|
||||
CREATE_CHANNEL_REQUIRED_STEP,
|
||||
CREATE_CHANNEL_STEPPER_TEXT,
|
||||
} from '../create-channel.constant';
|
||||
|
||||
interface IProps extends React.PropsWithChildren {
|
||||
actionButton?: React.ReactNode;
|
||||
validate?: () => Promise<boolean> | boolean;
|
||||
disableNextBtn?: boolean;
|
||||
isLoading?: boolean;
|
||||
onClickBack?: () => void;
|
||||
}
|
||||
|
||||
const CreateChannelInputTemplate: React.FC<IProps> = (props) => {
|
||||
const {
|
||||
children,
|
||||
actionButton,
|
||||
validate,
|
||||
disableNextBtn,
|
||||
isLoading,
|
||||
onClickBack,
|
||||
} = props;
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { currentStep, nextStep, prevStep, input, reset, jumpStepByKey } =
|
||||
useCreateChannelStore();
|
||||
|
||||
const router = useRouter();
|
||||
const projectId = Number(router.query.projectId);
|
||||
const overlay = useOverlay();
|
||||
const { refetch } = useAllChannels(projectId);
|
||||
|
||||
const openCreateChannelError = () => {
|
||||
overlay.open(({ isOpen, close }) => (
|
||||
<Dialog open={isOpen} onOpenChange={close}>
|
||||
<DialogContent>
|
||||
<DialogHeader>{t('text.guide')}</DialogHeader>
|
||||
<DialogBody>
|
||||
<DialogDescription>
|
||||
{t('main.create-channel.guide.invalid-channel')}
|
||||
</DialogDescription>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<DialogClose>{t('v2.button.cancel')}</DialogClose>
|
||||
<Button onClick={() => jumpStepByKey('channel-info')}>
|
||||
{t('v2.button.confirm')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
));
|
||||
};
|
||||
|
||||
const { mutate } = useOAIMutation({
|
||||
method: 'post',
|
||||
path: '/api/admin/projects/{projectId}/channels',
|
||||
pathParams: { projectId },
|
||||
queryOptions: {
|
||||
async onSuccess(data) {
|
||||
await refetch();
|
||||
await router.replace({
|
||||
pathname: Path.CREATE_CHANNEL_COMPLETE,
|
||||
query: { projectId, channelId: data?.id },
|
||||
});
|
||||
reset();
|
||||
},
|
||||
onError(error) {
|
||||
if (error.code === ErrorCode.Channel.ChannelAlreadyExists) {
|
||||
openCreateChannelError();
|
||||
} else {
|
||||
toast.error(error.message);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const isDefaultInput = useMemo(
|
||||
() => isObjectEqual(CREATE_CHANNEL_DEFAULT_INPUT, input),
|
||||
[input],
|
||||
);
|
||||
|
||||
useWarnIfSavedChanges(
|
||||
!isDefaultInput,
|
||||
Path.CREATE_CHANNEL_COMPLETE.replace(
|
||||
'[projectId]',
|
||||
router.query.projectId as string,
|
||||
),
|
||||
);
|
||||
|
||||
const onComplete = () => {
|
||||
mutate({
|
||||
...input.channelInfo,
|
||||
fields: input.fields.filter((v) => !isDefaultField(v)),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<CreateInputTemplate
|
||||
currentStepIndex={currentStep.index}
|
||||
lastStep={CREATE_CHANNEL_MAIN_STEP_LIST.length - 1}
|
||||
onNext={nextStep}
|
||||
onPrev={prevStep}
|
||||
title={CREATE_CHANNEL_STEPPER_TEXT[currentStep.key]}
|
||||
actionButton={actionButton}
|
||||
onComplete={onComplete}
|
||||
validate={validate}
|
||||
disableNextBtn={disableNextBtn ?? isLoading}
|
||||
helpText={CREATE_CHANNEL_HELP_TEXT[currentStep.key]}
|
||||
onClickBack={onClickBack}
|
||||
isRequiredStep={CREATE_CHANNEL_REQUIRED_STEP[currentStep.key]}
|
||||
>
|
||||
{children}
|
||||
</CreateInputTemplate>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateChannelInputTemplate;
|
||||
@@ -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 Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { CreationLayout, CreationStepper, Path } from '@/shared';
|
||||
|
||||
import { useCreateChannelStore } from '../create-channel-model';
|
||||
import { CREATE_CHANNEL_MAIN_STEP_LIST } from '../create-channel-type';
|
||||
import {
|
||||
CREATE_CHANNEL_COMPONENTS,
|
||||
CREATE_CHANNEL_STEPPER_TEXT,
|
||||
} from '../create-channel.constant';
|
||||
|
||||
interface IProps {}
|
||||
|
||||
const CreateChannel: React.FC<IProps> = () => {
|
||||
const { currentStep } = useCreateChannelStore();
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<CreationLayout
|
||||
title={t('main.create-channel.title')}
|
||||
leftPanel={
|
||||
<CreationStepper
|
||||
steps={CREATE_CHANNEL_MAIN_STEP_LIST}
|
||||
currentStepIndex={currentStep.index}
|
||||
stepTitle={CREATE_CHANNEL_STEPPER_TEXT}
|
||||
/>
|
||||
}
|
||||
leftBottom={
|
||||
<div className="flex justify-end">
|
||||
<Image
|
||||
src={`/assets/images/create-channel-${currentStep.key}.svg`}
|
||||
alt="Create Project"
|
||||
className="align"
|
||||
width={320}
|
||||
height={320}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
onClickGoBack={() =>
|
||||
router.push({
|
||||
pathname: Path.PROJECT_MAIN,
|
||||
query: { projectId: router.query.projectId },
|
||||
})
|
||||
}
|
||||
>
|
||||
{CREATE_CHANNEL_COMPONENTS[currentStep.key]}
|
||||
</CreationLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateChannel;
|
||||
@@ -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 CreateChannel } from './create-channel.ui';
|
||||
@@ -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 { useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
|
||||
import { useAllChannels } from '@/shared';
|
||||
import { EMPTY_FUNCTION } from '@/shared/utils/empty-function';
|
||||
import type { ChannelInfo } from '@/entities/channel';
|
||||
import { ChannelInfoForm, channelInfoSchema } from '@/entities/channel';
|
||||
|
||||
import { useCreateChannelStore } from '../create-channel-model';
|
||||
import CreateChannelInputTemplate from './create-channel-input-template.ui';
|
||||
|
||||
interface IProps {}
|
||||
|
||||
const InputChannelInfoStep: React.FC<IProps> = () => {
|
||||
const { onChangeInput, input, setEditingStepIndex } = useCreateChannelStore();
|
||||
const router = useRouter();
|
||||
const projectId = Number(router.query.projectId);
|
||||
const { data } = useAllChannels(projectId);
|
||||
|
||||
const methods = useForm<ChannelInfo>({
|
||||
resolver: zodResolver(channelInfoSchema),
|
||||
defaultValues: input.channelInfo,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = methods.watch((values) => {
|
||||
const newValues = channelInfoSchema.safeParse(values);
|
||||
if (!newValues.data) return;
|
||||
onChangeInput('channelInfo', newValues.data);
|
||||
setEditingStepIndex(0);
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<CreateChannelInputTemplate
|
||||
validate={async () => {
|
||||
const isValid = await methods.trigger();
|
||||
await methods.handleSubmit(EMPTY_FUNCTION)();
|
||||
const isDuplicated = data?.items.some(
|
||||
(v) => v.name === methods.getValues('name'),
|
||||
);
|
||||
if (isDuplicated) {
|
||||
methods.setError('name', { message: 'Duplicated name' });
|
||||
return false;
|
||||
}
|
||||
return isValid;
|
||||
}}
|
||||
>
|
||||
<FormProvider {...methods}>
|
||||
<ChannelInfoForm type="create" />
|
||||
</FormProvider>
|
||||
</CreateChannelInputTemplate>
|
||||
);
|
||||
};
|
||||
|
||||
export default InputChannelInfoStep;
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { PreviewFieldTable } from '@/entities/field';
|
||||
|
||||
import { useCreateChannelStore } from '../create-channel-model';
|
||||
import CreateChannelInputTemplate from './create-channel-input-template.ui';
|
||||
|
||||
interface IProps {}
|
||||
|
||||
const InputFieldPreviewStep: React.FC<IProps> = () => {
|
||||
const { input, jumpStepByKey } = useCreateChannelStore();
|
||||
|
||||
return (
|
||||
<CreateChannelInputTemplate onClickBack={() => jumpStepByKey('field')}>
|
||||
<PreviewFieldTable
|
||||
fields={input.fields.filter((v) => v.status === 'ACTIVE')}
|
||||
/>
|
||||
</CreateChannelInputTemplate>
|
||||
);
|
||||
};
|
||||
|
||||
export default InputFieldPreviewStep;
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { useOverlay } from '@toss/use-overlay';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { Icon, ToggleGroup, ToggleGroupItem } from '@ufb/react';
|
||||
|
||||
import { FieldSettingSheet } from '@/entities/field';
|
||||
import type { FieldInfo } from '@/entities/field';
|
||||
import FieldTable from '@/entities/field/ui/field-table.ui';
|
||||
|
||||
import { useCreateChannelStore } from '../create-channel-model';
|
||||
import CreateChannelInputTemplate from './create-channel-input-template.ui';
|
||||
|
||||
interface IProps {}
|
||||
|
||||
const InputFieldStep: React.FC<IProps> = () => {
|
||||
const { input, onChangeInput, jumpStepByKey } = useCreateChannelStore();
|
||||
const fields = input.fields;
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const overlay = useOverlay();
|
||||
|
||||
const createField = (field: FieldInfo) => {
|
||||
onChangeInput(
|
||||
'fields',
|
||||
input.fields.concat({ ...field, order: fields.length }),
|
||||
);
|
||||
};
|
||||
|
||||
const updateField = (input: { index: number; field: FieldInfo }) => {
|
||||
const { field, index } = input;
|
||||
onChangeInput(
|
||||
'fields',
|
||||
fields.map((v, i) => (i === index ? field : v)),
|
||||
);
|
||||
};
|
||||
|
||||
const deleteField = ({ index }: { index: number }) => {
|
||||
onChangeInput(
|
||||
'fields',
|
||||
input.fields.filter((_, i) => i !== index),
|
||||
);
|
||||
};
|
||||
|
||||
const openFieldFormSheet = (input?: { index: number; field: FieldInfo }) => {
|
||||
overlay.open(({ close, isOpen }) => (
|
||||
<FieldSettingSheet
|
||||
isOpen={isOpen}
|
||||
close={close}
|
||||
onSubmit={(newField) => {
|
||||
if (input) {
|
||||
updateField({ index: input.index, field: newField });
|
||||
return;
|
||||
}
|
||||
createField(newField);
|
||||
}}
|
||||
fieldRows={fields}
|
||||
data={input?.field}
|
||||
onClickDelete={
|
||||
input ?
|
||||
() => {
|
||||
deleteField({ index: input.index });
|
||||
close();
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<CreateChannelInputTemplate
|
||||
actionButton={
|
||||
<ToggleGroup type="single" value="">
|
||||
<ToggleGroupItem value="item-1" onClick={() => openFieldFormSheet()}>
|
||||
<Icon name="RiAddLine" />
|
||||
{t('v2.button.name.add', { name: 'Field' })}
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value="item-2"
|
||||
onClick={() => jumpStepByKey('field-preview')}
|
||||
>
|
||||
<Icon name="RiEyeLine" />
|
||||
{t('v2.text.preview')}
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
}
|
||||
>
|
||||
<FieldTable
|
||||
fields={input.fields}
|
||||
onClickRow={(index, field) => openFieldFormSheet({ index, field })}
|
||||
disableFilter
|
||||
/>
|
||||
</CreateChannelInputTemplate>
|
||||
);
|
||||
};
|
||||
|
||||
export default InputFieldStep;
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES 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';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
import type { ApiKey } from '@/entities/api-key';
|
||||
import type { IssueTracker } from '@/entities/issue-tracker';
|
||||
import type { MemberInfo } from '@/entities/member';
|
||||
import { getDefaultTimezone } from '@/entities/project';
|
||||
import type { ProjectInfo } from '@/entities/project';
|
||||
import { PermissionList } from '@/entities/role';
|
||||
import type { PermissionType, Role } from '@/entities/role';
|
||||
|
||||
import type { CreateProjectStepKey } from './create-project-type';
|
||||
import {
|
||||
CREATE_PROJECT_MAIN_STEP_LIST,
|
||||
CREATE_PROJECT_STEP_MAP,
|
||||
FIRST_CREATE_PROJECT_STEP,
|
||||
LAST_CREATE_PROJECT_STEP,
|
||||
} from './create-project-type';
|
||||
|
||||
const DEFAULT_ROLES: Role[] = [
|
||||
{ id: 1, name: 'PROJECT_MANAGER', permissions: [...PermissionList] },
|
||||
{ id: 2, name: 'END_USER', permissions: [] },
|
||||
{ id: 3, name: 'FEEDBACK_PROVIDER', permissions: [] },
|
||||
];
|
||||
|
||||
interface Input {
|
||||
projectInfo: ProjectInfo;
|
||||
roles: {
|
||||
id: number;
|
||||
name: string;
|
||||
permissions: PermissionType[];
|
||||
}[];
|
||||
members: MemberInfo[];
|
||||
issueTracker: IssueTracker;
|
||||
apiKeys: ApiKey[];
|
||||
}
|
||||
|
||||
interface Step {
|
||||
key: CreateProjectStepKey;
|
||||
index: number;
|
||||
}
|
||||
|
||||
interface State {
|
||||
editingStepIndex: number | null;
|
||||
currentStep: Step;
|
||||
input: Input;
|
||||
}
|
||||
|
||||
interface Action {
|
||||
setEditingStepIndex: (index: number) => void;
|
||||
jumpStepByIndex: (index: number) => void;
|
||||
jumpStepByKey: (key: CreateProjectStepKey) => void;
|
||||
prevStep: () => void;
|
||||
nextStep: () => void;
|
||||
reset: () => void;
|
||||
onChangeInput: <T extends keyof Input>(key: T, value: Input[T]) => void;
|
||||
}
|
||||
export const CREATE_PROJECT_DEFAULT_INPUT: Input = {
|
||||
projectInfo: {
|
||||
name: '',
|
||||
description: '',
|
||||
timezone: getDefaultTimezone(),
|
||||
},
|
||||
roles: DEFAULT_ROLES,
|
||||
members: [],
|
||||
issueTracker: {
|
||||
provider: 'jira',
|
||||
ticketDomain: '',
|
||||
ticketKey: '',
|
||||
},
|
||||
apiKeys: [],
|
||||
};
|
||||
|
||||
const DEFAULT_STATE: State = {
|
||||
editingStepIndex: null,
|
||||
currentStep: {
|
||||
index: 0,
|
||||
key: 'project-info',
|
||||
},
|
||||
input: CREATE_PROJECT_DEFAULT_INPUT,
|
||||
};
|
||||
|
||||
export const useCreateProjectStore = create<State & Action>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
...DEFAULT_STATE,
|
||||
setEditingStepIndex(index) {
|
||||
set({ editingStepIndex: index });
|
||||
},
|
||||
onChangeInput: <T extends keyof Input>(key: T, value: Input[T]) => {
|
||||
set(({ input }) => ({ input: { ...input, [key]: value } }));
|
||||
},
|
||||
jumpStepByIndex(index) {
|
||||
set({
|
||||
currentStep: {
|
||||
index,
|
||||
key: CREATE_PROJECT_MAIN_STEP_LIST[index] ?? 'project-info',
|
||||
},
|
||||
});
|
||||
},
|
||||
jumpStepByKey(key) {
|
||||
set({ currentStep: { index: CREATE_PROJECT_STEP_MAP[key], key } });
|
||||
},
|
||||
nextStep() {
|
||||
set(({ currentStep }) => ({
|
||||
currentStep: {
|
||||
index: Math.min(currentStep.index + 1, LAST_CREATE_PROJECT_STEP),
|
||||
key:
|
||||
CREATE_PROJECT_MAIN_STEP_LIST[currentStep.index + 1] ??
|
||||
'project-info',
|
||||
},
|
||||
editingStepIndex: Math.min(
|
||||
currentStep.index + 1,
|
||||
LAST_CREATE_PROJECT_STEP,
|
||||
),
|
||||
}));
|
||||
},
|
||||
prevStep() {
|
||||
set(({ currentStep }) => ({
|
||||
currentStep: {
|
||||
index: Math.max(currentStep.index - 1, FIRST_CREATE_PROJECT_STEP),
|
||||
key:
|
||||
CREATE_PROJECT_MAIN_STEP_LIST[currentStep.index - 1] ??
|
||||
'project-info',
|
||||
},
|
||||
}));
|
||||
},
|
||||
reset() {
|
||||
set({ ...DEFAULT_STATE });
|
||||
},
|
||||
}),
|
||||
{ name: 'create-project', version: 2, migrate: () => DEFAULT_STATE },
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* 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 const CREATE_PROJECT_STEP_KEY_LIST = [
|
||||
'project-info',
|
||||
'members',
|
||||
'roles',
|
||||
'api-keys',
|
||||
] as const;
|
||||
|
||||
export const CREATE_PROJECT_STEP_MAP: Record<CreateProjectStepKey, number> = {
|
||||
'project-info': 0,
|
||||
members: 1,
|
||||
roles: 1,
|
||||
'api-keys': 2,
|
||||
};
|
||||
|
||||
export const CREATE_PROJECT_MAIN_STEP_LIST: CreateProjectStepKey[] = [
|
||||
'project-info',
|
||||
'members',
|
||||
'api-keys',
|
||||
];
|
||||
|
||||
export const LAST_CREATE_PROJECT_STEP = CREATE_PROJECT_STEP_KEY_LIST.length;
|
||||
export const FIRST_CREATE_PROJECT_STEP = 0;
|
||||
|
||||
export type CreateProjectStepKey =
|
||||
(typeof CREATE_PROJECT_STEP_KEY_LIST)[number];
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { Trans } from 'next-i18next';
|
||||
|
||||
import { HelpCardDocs } from '@/shared';
|
||||
|
||||
import type { CreateProjectStepKey } from './create-project-type';
|
||||
import InputApiKeyStep from './ui/input-api-key-step.ui';
|
||||
import InputMembersStep from './ui/input-members-step.ui';
|
||||
import InputProjectInfoStep from './ui/input-project-info-step.ui';
|
||||
import InputRolesStep from './ui/input-roles-step.ui';
|
||||
|
||||
export const CREATE_PROJECT_COMPONENTS: Record<
|
||||
CreateProjectStepKey,
|
||||
React.ReactNode
|
||||
> = {
|
||||
'project-info': <InputProjectInfoStep />,
|
||||
roles: <InputRolesStep />,
|
||||
'api-keys': <InputApiKeyStep />,
|
||||
members: <InputMembersStep />,
|
||||
};
|
||||
|
||||
export const CREATE_PROJECT_STEPPER_TEXT: Record<
|
||||
CreateProjectStepKey,
|
||||
React.ReactNode
|
||||
> = {
|
||||
'project-info': <Trans i18nKey="v2.project-setting-menu.project-info" />,
|
||||
roles: <Trans i18nKey="v2.project-setting-menu.role-mgmt" />,
|
||||
members: <Trans i18nKey="v2.project-setting-menu.member-mgmt" />,
|
||||
'api-keys': <Trans i18nKey="v2.project-setting-menu.api-key-mgmt" />,
|
||||
};
|
||||
|
||||
export const CREATE_PROJECT_HELP_TEXT: Record<
|
||||
CreateProjectStepKey,
|
||||
React.ReactNode | undefined
|
||||
> = {
|
||||
'project-info': <Trans i18nKey="help-card.project-info" />,
|
||||
roles: undefined,
|
||||
members: <Trans i18nKey="help-card.member" />,
|
||||
'api-keys': <HelpCardDocs i18nKey="help-card.api-key" />,
|
||||
};
|
||||
|
||||
export const CREATE_PROJECT_REQUIRED_STEP: Record<
|
||||
CreateProjectStepKey,
|
||||
boolean
|
||||
> = {
|
||||
'project-info': true,
|
||||
members: false,
|
||||
roles: false,
|
||||
'api-keys': false,
|
||||
};
|
||||
@@ -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,188 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES 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 { useOverlay } from '@toss/use-overlay';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
toast,
|
||||
} from '@ufb/react';
|
||||
import { ErrorCode } from '@ufb/shared';
|
||||
|
||||
import {
|
||||
CreateInputTemplate,
|
||||
isObjectEqual,
|
||||
Path,
|
||||
useAllProjects,
|
||||
useOAIMutation,
|
||||
useWarnIfSavedChanges,
|
||||
} from '@/shared';
|
||||
|
||||
import {
|
||||
CREATE_PROJECT_DEFAULT_INPUT,
|
||||
useCreateProjectStore,
|
||||
} from '../create-project-model';
|
||||
import { CREATE_PROJECT_MAIN_STEP_LIST } from '../create-project-type';
|
||||
import {
|
||||
CREATE_PROJECT_HELP_TEXT,
|
||||
CREATE_PROJECT_REQUIRED_STEP,
|
||||
CREATE_PROJECT_STEPPER_TEXT,
|
||||
} from '../create-project.constant';
|
||||
|
||||
interface IProps extends React.PropsWithChildren {
|
||||
actionButton?: React.ReactNode;
|
||||
validate?: () => Promise<boolean> | boolean;
|
||||
disableNextBtn?: boolean;
|
||||
isLoading?: boolean;
|
||||
onClickBack?: () => void;
|
||||
scrollable?: boolean;
|
||||
}
|
||||
|
||||
const CreateProjectInputTemplate: React.FC<IProps> = (props) => {
|
||||
const {
|
||||
children,
|
||||
actionButton,
|
||||
validate,
|
||||
disableNextBtn,
|
||||
isLoading,
|
||||
onClickBack,
|
||||
scrollable = false,
|
||||
} = props;
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { currentStep, nextStep, prevStep, input, jumpStepByKey, reset } =
|
||||
useCreateProjectStore();
|
||||
|
||||
const router = useRouter();
|
||||
const overlay = useOverlay();
|
||||
const { refetch } = useAllProjects();
|
||||
|
||||
const openMemberError = () => {
|
||||
overlay.open(({ isOpen, close }) => (
|
||||
<Dialog open={isOpen} onOpenChange={close}>
|
||||
<DialogContent>
|
||||
<DialogTitle>{t('text.guide')}</DialogTitle>
|
||||
<DialogBody>
|
||||
{t('main.create-project.guide.invalid-member')}
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<DialogClose
|
||||
variant="primary"
|
||||
onClick={() => jumpStepByKey('members')}
|
||||
>
|
||||
{t('v2.button.confirm')}
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
));
|
||||
};
|
||||
const openProjectError = () => {
|
||||
overlay.open(({ isOpen, close }) => (
|
||||
<Dialog open={isOpen} onOpenChange={close}>
|
||||
<DialogContent>
|
||||
<DialogTitle>{t('text.guide')}</DialogTitle>
|
||||
<DialogBody>
|
||||
{t('main.create-project.guide.invalid-project')}
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<DialogClose
|
||||
variant="primary"
|
||||
onClick={() => jumpStepByKey('project-info')}
|
||||
>
|
||||
{t('v2.button.confirm')}
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
));
|
||||
};
|
||||
|
||||
const { mutate } = useOAIMutation({
|
||||
method: 'post',
|
||||
path: '/api/admin/projects',
|
||||
queryOptions: {
|
||||
async onSuccess(data) {
|
||||
await refetch();
|
||||
await router.push({
|
||||
pathname: Path.CREATE_PROJECT_COMPLETE,
|
||||
query: { projectId: data?.id },
|
||||
});
|
||||
reset();
|
||||
},
|
||||
onError(error) {
|
||||
if (error.code === ErrorCode.Member.MemberInvalidUser) {
|
||||
openMemberError();
|
||||
} else if (error.code === ErrorCode.Project.ProjectAlreadyExists) {
|
||||
openProjectError();
|
||||
} else {
|
||||
toast.error(error.message);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const isDefaultInput = useMemo(
|
||||
() => isObjectEqual(CREATE_PROJECT_DEFAULT_INPUT, input),
|
||||
[input],
|
||||
);
|
||||
|
||||
useWarnIfSavedChanges(!isDefaultInput, Path.CREATE_PROJECT_COMPLETE);
|
||||
|
||||
const onComplete = () => {
|
||||
mutate({
|
||||
...input.projectInfo,
|
||||
roles: input.roles,
|
||||
apiKeys: input.apiKeys,
|
||||
issueTracker: { data: input.issueTracker },
|
||||
members: input.members.map(({ role, user }) => ({
|
||||
roleName: role.name,
|
||||
userId: user.id,
|
||||
})),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<CreateInputTemplate
|
||||
currentStepIndex={currentStep.index}
|
||||
lastStep={CREATE_PROJECT_MAIN_STEP_LIST.length - 1}
|
||||
onNext={nextStep}
|
||||
onPrev={prevStep}
|
||||
title={CREATE_PROJECT_STEPPER_TEXT[currentStep.key]}
|
||||
actionButton={actionButton}
|
||||
onComplete={onComplete}
|
||||
validate={validate}
|
||||
disableNextBtn={disableNextBtn ?? isLoading}
|
||||
helpText={CREATE_PROJECT_HELP_TEXT[currentStep.key]}
|
||||
onClickBack={onClickBack}
|
||||
scrollable={scrollable}
|
||||
isRequiredStep={CREATE_PROJECT_REQUIRED_STEP[currentStep.key]}
|
||||
>
|
||||
{children}
|
||||
</CreateInputTemplate>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateProjectInputTemplate;
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { CreationLayout, CreationStepper, Path } from '@/shared';
|
||||
|
||||
import { useCreateProjectStore } from '../create-project-model';
|
||||
import { CREATE_PROJECT_MAIN_STEP_LIST } from '../create-project-type';
|
||||
import {
|
||||
CREATE_PROJECT_COMPONENTS,
|
||||
CREATE_PROJECT_STEPPER_TEXT,
|
||||
} from '../create-project.constant';
|
||||
|
||||
interface IProps {}
|
||||
|
||||
const CreateProject: React.FC<IProps> = () => {
|
||||
const { t } = useTranslation();
|
||||
const { currentStep } = useCreateProjectStore();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<CreationLayout
|
||||
title={t('main.create-project.title')}
|
||||
leftPanel={
|
||||
<CreationStepper
|
||||
steps={CREATE_PROJECT_MAIN_STEP_LIST}
|
||||
currentStepIndex={currentStep.index}
|
||||
stepTitle={CREATE_PROJECT_STEPPER_TEXT}
|
||||
/>
|
||||
}
|
||||
leftBottom={
|
||||
<div className="flex justify-end">
|
||||
<Image
|
||||
src={`/assets/images/create-project-step-${currentStep.index + 1}.svg`}
|
||||
alt="Create Project"
|
||||
className="align"
|
||||
width={320}
|
||||
height={320}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
onClickGoBack={() => router.push({ pathname: Path.MAIN })}
|
||||
>
|
||||
{CREATE_PROJECT_COMPONENTS[currentStep.key]}
|
||||
</CreationLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateProject;
|
||||
@@ -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 CreateProject } from './create-project.ui';
|
||||
@@ -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 { randomBytes } from 'crypto';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { Button } from '@ufb/react';
|
||||
|
||||
import type { ApiKeyUpdateType } from '@/entities/api-key';
|
||||
import { ApiKeyTable } from '@/entities/api-key';
|
||||
|
||||
import { useCreateProjectStore } from '../create-project-model';
|
||||
import CreateProjectInputTemplate from './create-project-input-template.ui';
|
||||
|
||||
interface IProps {}
|
||||
|
||||
const InputApiKeyStep: React.FC<IProps> = () => {
|
||||
const { input, onChangeInput } = useCreateProjectStore();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const createApiKey = () => {
|
||||
onChangeInput(
|
||||
'apiKeys',
|
||||
input.apiKeys.concat({
|
||||
id: (input.apiKeys[input.apiKeys.length - 1]?.id ?? 0) + 1,
|
||||
value: randomBytes(10).toString('hex').toUpperCase(),
|
||||
createdAt: new Date().toISOString(),
|
||||
deletedAt: null,
|
||||
}),
|
||||
);
|
||||
};
|
||||
const updateApiKey = (type: ApiKeyUpdateType, id: number) => {
|
||||
onChangeInput(
|
||||
'apiKeys',
|
||||
input.apiKeys.map((apiKey) =>
|
||||
apiKey.id === id ?
|
||||
{
|
||||
...apiKey,
|
||||
deletedAt: type === 'recover' ? null : new Date().toISOString(),
|
||||
}
|
||||
: apiKey,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const deleteApiKey = (id: number) => {
|
||||
onChangeInput(
|
||||
'apiKeys',
|
||||
input.apiKeys.filter((v) => v.id !== id),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<CreateProjectInputTemplate
|
||||
actionButton={
|
||||
<Button className="min-w-[120px]" onClick={createApiKey}>
|
||||
{t('v2.button.name.create', { name: 'API Key' })}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<ApiKeyTable
|
||||
data={input.apiKeys}
|
||||
onClickUpdate={updateApiKey}
|
||||
onClickDelete={deleteApiKey}
|
||||
/>
|
||||
</CreateProjectInputTemplate>
|
||||
);
|
||||
};
|
||||
|
||||
export default InputApiKeyStep;
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES 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 { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
|
||||
import { EMPTY_FUNCTION } from '@/shared/utils/empty-function';
|
||||
import { IssueTrackerForm, issueTrackerSchema } from '@/entities/issue-tracker';
|
||||
import type { IssueTracker } from '@/entities/issue-tracker';
|
||||
|
||||
import { useCreateProjectStore } from '../create-project-model';
|
||||
import CreateProjectInputTemplate from './create-project-input-template.ui';
|
||||
|
||||
interface IProps {}
|
||||
|
||||
const InputIssueTrackerStep: React.FC<IProps> = () => {
|
||||
const { input, onChangeInput } = useCreateProjectStore();
|
||||
|
||||
const methods = useForm<IssueTracker>({
|
||||
resolver: zodResolver(issueTrackerSchema),
|
||||
defaultValues: input.issueTracker,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = methods.watch((values) => {
|
||||
const newValues = issueTrackerSchema.safeParse(values);
|
||||
if (!newValues.data) return;
|
||||
onChangeInput('issueTracker', newValues.data);
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<CreateProjectInputTemplate
|
||||
validate={async () => {
|
||||
const isValid = await methods.trigger();
|
||||
await methods.handleSubmit(EMPTY_FUNCTION)();
|
||||
return isValid;
|
||||
}}
|
||||
>
|
||||
<FormProvider {...methods}>
|
||||
<IssueTrackerForm />
|
||||
</FormProvider>
|
||||
</CreateProjectInputTemplate>
|
||||
);
|
||||
};
|
||||
|
||||
export default InputIssueTrackerStep;
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import {
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table';
|
||||
import { useOverlay } from '@toss/use-overlay';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
Icon,
|
||||
} from '@ufb/react';
|
||||
|
||||
import { BasicTable } from '@/shared';
|
||||
import type { MemberInfo } from '@/entities/member';
|
||||
import { MemberFormDialog } from '@/entities/member';
|
||||
import { memberColumns } from '@/entities/member/member-columns';
|
||||
import { useUserSearch } from '@/entities/user';
|
||||
|
||||
import { useCreateProjectStore } from '../create-project-model';
|
||||
import CreateProjectInputTemplate from './create-project-input-template.ui';
|
||||
|
||||
interface IProps {}
|
||||
|
||||
const InputMembersStep: React.FC<IProps> = () => {
|
||||
const { input, onChangeInput, jumpStepByKey } = useCreateProjectStore();
|
||||
const overlay = useOverlay();
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { data: userData } = useUserSearch({
|
||||
limit: 1000,
|
||||
queries: [{ key: 'type', value: ['GENERAL'], condition: 'IS' }],
|
||||
});
|
||||
|
||||
const createMember = (member: MemberInfo) => {
|
||||
onChangeInput('members', input.members.concat(member));
|
||||
};
|
||||
|
||||
const updateMember = (index: number, newMember: MemberInfo) => {
|
||||
onChangeInput(
|
||||
'members',
|
||||
input.members.map((m, i) => (i === index ? newMember : m)),
|
||||
);
|
||||
};
|
||||
|
||||
const deleteMember = (index: number) => {
|
||||
onChangeInput(
|
||||
'members',
|
||||
input.members.filter((_, i) => index !== i),
|
||||
);
|
||||
};
|
||||
|
||||
const openInvalidateMemberModal = () => {
|
||||
overlay.open(({ isOpen, close }) => (
|
||||
<Dialog open={isOpen} onOpenChange={close}>
|
||||
<DialogContent>
|
||||
<DialogTitle>{t('text.guide')}</DialogTitle>
|
||||
<DialogBody>
|
||||
{t('main.create-project.guide.invalid-member')}
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<DialogClose variant="primary">
|
||||
{t('v2.button.confirm')}
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
));
|
||||
};
|
||||
|
||||
const validate = () => {
|
||||
if (!userData) return false;
|
||||
if (
|
||||
!input.members.every((member) =>
|
||||
userData.items.some((user) => user.id === member.user.id),
|
||||
)
|
||||
) {
|
||||
openInvalidateMemberModal();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const openCreateMemberFormDialog = () => {
|
||||
overlay.open(({ isOpen, close }) => (
|
||||
<MemberFormDialog
|
||||
members={input.members}
|
||||
onSubmit={({ role, user }) => createMember({ role, user })}
|
||||
project={input.projectInfo}
|
||||
roles={input.roles}
|
||||
close={close}
|
||||
isOpen={isOpen}
|
||||
/>
|
||||
));
|
||||
};
|
||||
|
||||
const openUpdateMemberFormDialog = (index: number, member: MemberInfo) => {
|
||||
overlay.open(({ close, isOpen }) => (
|
||||
<MemberFormDialog
|
||||
close={close}
|
||||
isOpen={isOpen}
|
||||
data={member}
|
||||
onSubmit={(newMember) => updateMember(index, newMember)}
|
||||
onClickDelete={() => deleteMember(index)}
|
||||
project={input.projectInfo}
|
||||
roles={input.roles}
|
||||
members={input.members}
|
||||
/>
|
||||
));
|
||||
};
|
||||
|
||||
const table = useReactTable({
|
||||
columns: memberColumns,
|
||||
data: input.members,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
});
|
||||
return (
|
||||
<CreateProjectInputTemplate
|
||||
validate={validate}
|
||||
actionButton={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
jumpStepByKey('roles');
|
||||
}}
|
||||
>
|
||||
<Icon name="RiExchange2Fill" />
|
||||
{t('v2.project-setting-menu.role-mgmt')}
|
||||
</Button>
|
||||
<Button onClick={openCreateMemberFormDialog}>
|
||||
{t('v2.button.name.register', { name: 'Member' })}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<BasicTable table={table} onClickRow={openUpdateMemberFormDialog} />
|
||||
</CreateProjectInputTemplate>
|
||||
);
|
||||
};
|
||||
|
||||
export default InputMembersStep;
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES 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 { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
|
||||
import { client } from '@/shared';
|
||||
import { EMPTY_FUNCTION } from '@/shared/utils/empty-function';
|
||||
import type { ProjectInfo } from '@/entities/project';
|
||||
import { ProjectInfoForm, projectInfoSchema } from '@/entities/project';
|
||||
|
||||
import { useCreateProjectStore } from '../create-project-model';
|
||||
import CreateProjectInputTemplate from './create-project-input-template.ui';
|
||||
|
||||
interface IProps {}
|
||||
|
||||
const InputProjectInfo: React.FC<IProps> = () => {
|
||||
const { onChangeInput, input, setEditingStepIndex } = useCreateProjectStore();
|
||||
|
||||
const methods = useForm<ProjectInfo>({
|
||||
resolver: zodResolver(projectInfoSchema),
|
||||
defaultValues: input.projectInfo,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = methods.watch((values) => {
|
||||
const newValues = projectInfoSchema.safeParse(values);
|
||||
if (!newValues.data) return;
|
||||
onChangeInput('projectInfo', newValues.data);
|
||||
setEditingStepIndex(0);
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
}, []);
|
||||
|
||||
const validate = async () => {
|
||||
const isValid = await methods.trigger();
|
||||
await methods.handleSubmit(EMPTY_FUNCTION)();
|
||||
const response = await client.get({
|
||||
path: '/api/admin/projects/name-check',
|
||||
query: { name: methods.getValues('name') },
|
||||
});
|
||||
|
||||
const isDuplicated = response.data as unknown as boolean;
|
||||
|
||||
if (isDuplicated) {
|
||||
methods.setError('name', { message: 'Duplicated name' });
|
||||
return false;
|
||||
}
|
||||
return isValid;
|
||||
};
|
||||
|
||||
return (
|
||||
<CreateProjectInputTemplate validate={validate}>
|
||||
<FormProvider {...methods}>
|
||||
<ProjectInfoForm type="create" />
|
||||
</FormProvider>
|
||||
</CreateProjectInputTemplate>
|
||||
);
|
||||
};
|
||||
|
||||
export default InputProjectInfo;
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useOverlay } from '@toss/use-overlay';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { Button } from '@ufb/react';
|
||||
|
||||
import type { PermissionType, Role } from '@/entities/role';
|
||||
import { RoleFormSheet, RoleTable } from '@/entities/role';
|
||||
|
||||
import { useCreateProjectStore } from '../create-project-model';
|
||||
import CreateProjectInputTemplate from './create-project-input-template.ui';
|
||||
|
||||
interface IProps {}
|
||||
|
||||
const InputRolesStep: React.FC<IProps> = () => {
|
||||
const { onChangeInput, input, jumpStepByKey } = useCreateProjectStore();
|
||||
const overlay = useOverlay();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const onCreateRole = (name: string, permissions: PermissionType[]) => {
|
||||
onChangeInput(
|
||||
'roles',
|
||||
input.roles.concat({
|
||||
id: (input.roles[input.roles.length - 1]?.id ?? 0) + 1,
|
||||
name,
|
||||
permissions,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const onUpdateRole = (role: Role) => {
|
||||
onChangeInput(
|
||||
'roles',
|
||||
input.roles.map((v) => (v.id === role.id ? role : v)),
|
||||
);
|
||||
};
|
||||
|
||||
const onDeleteRole = (role: Role) => {
|
||||
onChangeInput(
|
||||
'roles',
|
||||
input.roles.filter((v) => v.id !== role.id),
|
||||
);
|
||||
};
|
||||
|
||||
const openUpdateRoleSheet = (role: Role) => {
|
||||
overlay.open(({ isOpen, close }) => (
|
||||
<RoleFormSheet
|
||||
isOpen={isOpen}
|
||||
close={close}
|
||||
data={role}
|
||||
rows={input.roles}
|
||||
onSubmit={({ name, permissions }) => {
|
||||
onUpdateRole({ name, permissions, id: role.id });
|
||||
close();
|
||||
}}
|
||||
onClickDelete={() => {
|
||||
onDeleteRole(role);
|
||||
close();
|
||||
}}
|
||||
/>
|
||||
));
|
||||
};
|
||||
|
||||
const openCreateRoleSheet = () => {
|
||||
overlay.open(({ isOpen, close }) => (
|
||||
<RoleFormSheet
|
||||
isOpen={isOpen}
|
||||
close={close}
|
||||
rows={input.roles}
|
||||
onSubmit={({ name, permissions }) => {
|
||||
onCreateRole(name, permissions);
|
||||
close();
|
||||
}}
|
||||
/>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<CreateProjectInputTemplate
|
||||
onClickBack={() => jumpStepByKey('members')}
|
||||
actionButton={
|
||||
<Button onClick={openCreateRoleSheet}>
|
||||
{t('v2.button.name.create', { name: 'Role' })}
|
||||
</Button>
|
||||
}
|
||||
disableNextBtn={input.roles.length === 0}
|
||||
scrollable
|
||||
>
|
||||
<RoleTable roles={input.roles} onClickRole={openUpdateRoleSheet} />
|
||||
</CreateProjectInputTemplate>
|
||||
);
|
||||
};
|
||||
|
||||
export default InputRolesStep;
|
||||
@@ -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 { z } from 'zod';
|
||||
|
||||
export const createTenantFormSchema = z.object({
|
||||
siteName: z.string().trim().min(2).max(20),
|
||||
});
|
||||
@@ -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 { createTenantFormSchema } from './create-tenant-form.schema';
|
||||
|
||||
export type CreateTenant = z.infer<typeof createTenantFormSchema>;
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES 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 { Button } from '@ufb/react';
|
||||
|
||||
import { TextInput } from '@/shared';
|
||||
|
||||
import { createTenantFormSchema } from './create-tenant-form.schema';
|
||||
import type { CreateTenant } from './create-tenant-form.type';
|
||||
|
||||
interface IProps {
|
||||
onSubmit: (data: CreateTenant) => void;
|
||||
submitText: string;
|
||||
defaultValues?: CreateTenant | null;
|
||||
}
|
||||
|
||||
const CreateTenantForm: React.FC<IProps> = (props) => {
|
||||
const { onSubmit, submitText, defaultValues } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { register, handleSubmit, formState } = useForm<CreateTenant>({
|
||||
resolver: zodResolver(createTenantFormSchema),
|
||||
defaultValues: defaultValues ?? undefined,
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
className="flex h-full flex-col gap-4"
|
||||
onSubmit={handleSubmit((data) => onSubmit(data))}
|
||||
>
|
||||
<div className="flex flex-1 flex-col gap-4">
|
||||
<TextInput
|
||||
label="Name"
|
||||
placeholder={t('v2.placeholder.text')}
|
||||
error={formState.errors.siteName?.message}
|
||||
{...register('siteName')}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit">{submitText}</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateTenantForm;
|
||||
@@ -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 const DEFAULT_SUPER_ACCOUNT = {
|
||||
email: 'sdi9429@naver.com',
|
||||
password: '12345678!',
|
||||
};
|
||||
@@ -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 CreateTenantForm } from './create-tenant-form.ui';
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
const seoulDateTimeFormatter = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Seoul',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
export const formatSupportTimestamp = (value: string) => {
|
||||
// Legacy Secretary responses may contain a timezone-less ISO value. Those
|
||||
// values were stored/read as UTC, so make that interpretation explicit.
|
||||
const hasTimezone = /(?:Z|[+-]\d{2}:\d{2})$/i.test(value);
|
||||
const date = new Date(value + (hasTimezone ? "" : "Z"));
|
||||
const parts = seoulDateTimeFormatter.formatToParts(date);
|
||||
|
||||
const year = parts.find((part) => part.type === 'year')?.value ?? '0000';
|
||||
const month = parts.find((part) => part.type === 'month')?.value ?? '00';
|
||||
const day = parts.find((part) => part.type === 'day')?.value ?? '00';
|
||||
const hour = parts.find((part) => part.type === 'hour')?.value ?? '00';
|
||||
const minute = parts.find((part) => part.type === 'minute')?.value ?? '00';
|
||||
|
||||
return `${year}-${month}-${day} ${hour}:${minute}`;
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* 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 const SUPPORT_CATEGORY_OPTIONS = [
|
||||
{ code: 'ERROR_QNA', label: '오류문의' },
|
||||
{ code: 'IMPROVEMENT_QNA', label: '개선문의' },
|
||||
{ code: 'GENERAL_QNA', label: '일반문의' },
|
||||
] as const;
|
||||
|
||||
export type SupportCategoryCode =
|
||||
(typeof SUPPORT_CATEGORY_OPTIONS)[number]['code'];
|
||||
|
||||
export const SUPPORT_CATEGORY_LABELS: Record<string, string> = {
|
||||
ERROR_QNA: '오류문의',
|
||||
IMPROVEMENT_QNA: '개선문의',
|
||||
GENERAL_QNA: '일반문의',
|
||||
GENERAL: '일반문의',
|
||||
};
|
||||
|
||||
export const getSupportCategoryLabel = (
|
||||
categoryCode: string | null | undefined,
|
||||
) => {
|
||||
const normalized = categoryCode?.trim().toUpperCase() ?? '';
|
||||
return SUPPORT_CATEGORY_LABELS[normalized] ?? categoryCode ?? '일반문의';
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import Link from 'next/link';
|
||||
|
||||
interface Props extends React.PropsWithChildren {
|
||||
currentTab: 'ops' | 'admin';
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description: string;
|
||||
aside?: React.ReactNode;
|
||||
}
|
||||
|
||||
const tabClassName = (active: boolean) => {
|
||||
return active ?
|
||||
'bg-[#173629] text-white shadow-[0_14px_34px_rgba(23,54,41,0.2)]'
|
||||
: 'bg-white/70 text-[#294234] hover:bg-white';
|
||||
};
|
||||
|
||||
const SupportOperationsShell = (props: Props) => {
|
||||
const { currentTab, eyebrow, title, description, aside, children } = props;
|
||||
|
||||
return (
|
||||
<div className="min-h-full bg-[radial-gradient(circle_at_top_left,_rgba(227,240,231,0.92),_transparent_28%),linear-gradient(145deg,_#f4efe4_0%,_#e8f0ea_56%,_#dde8e4_100%)] px-6 py-8 text-[#1b2f23]">
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-6">
|
||||
<section className="overflow-hidden rounded-[36px] border border-white/75 bg-[linear-gradient(135deg,_rgba(250,249,244,0.95),_rgba(238,247,240,0.9))] shadow-[0_24px_80px_rgba(34,61,45,0.12)]">
|
||||
<div className="grid gap-6 px-8 py-8 lg:grid-cols-[minmax(0,1.4fr)_320px] lg:px-10">
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="rounded-full border border-[#d3ddcf] bg-white/70 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.28em] text-[#516754]">
|
||||
{eyebrow}
|
||||
</span>
|
||||
<span className="rounded-full bg-[#d8e6d8] px-3 py-1 text-xs font-medium text-[#32503f]">
|
||||
secretary-api workflow
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm uppercase tracking-[0.32em] text-[#708373]">
|
||||
Baron Support Operations
|
||||
</p>
|
||||
<h1 className="max-w-3xl text-4xl font-semibold tracking-[-0.03em] text-[#163122] lg:text-5xl">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="max-w-3xl whitespace-pre-line text-sm leading-7 text-[#526556] lg:text-base">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link
|
||||
href="/ops"
|
||||
className={`rounded-full px-5 py-2 text-sm font-medium transition ${tabClassName(currentTab === 'ops')}`}
|
||||
>
|
||||
운영 승인 큐
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/issues"
|
||||
className={`rounded-full px-5 py-2 text-sm font-medium transition ${tabClassName(currentTab === 'admin')}`}
|
||||
>
|
||||
관리자 이슈 연결
|
||||
</Link>
|
||||
<Link
|
||||
href="/support/INTRA_BOOK_REQUEST/new"
|
||||
className="rounded-full bg-[#f3ead7] px-5 py-2 text-sm font-medium text-[#5c4b30] transition hover:bg-[#ecdfc4]"
|
||||
>
|
||||
사용자 작성 화면
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col justify-between gap-4 rounded-[28px] border border-[#dce7dc] bg-white/75 p-6 backdrop-blur">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.24em] text-[#6d8272]">
|
||||
Workbench note
|
||||
</p>
|
||||
<h2 className="mt-3 text-2xl font-semibold tracking-[-0.02em] text-[#203425]">
|
||||
Approval to issue handoff
|
||||
</h2>
|
||||
</div>
|
||||
<div className="grid gap-3 text-sm text-[#4f6254]">
|
||||
<div className="rounded-2xl bg-[#eef4eb] px-4 py-3">
|
||||
운영 승인 이후에만 관리자 이슈 연결이 열리는 흐름을 같은 스텁 저장소로 검증합니다.
|
||||
</div>
|
||||
{aside}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SupportOperationsShell;
|
||||
@@ -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 Link from 'next/link';
|
||||
|
||||
import { useUserStore } from '@/entities/user';
|
||||
|
||||
interface Props extends React.PropsWithChildren {
|
||||
workspaceCode: string;
|
||||
workspaceName: string;
|
||||
currentTab: 'new' | 'list' | 'detail';
|
||||
eyebrow?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
aside?: React.ReactNode;
|
||||
hideTabs?: boolean;
|
||||
titleOnly?: boolean;
|
||||
hideHeader?: boolean;
|
||||
}
|
||||
|
||||
const tabClassName = (active: boolean) => {
|
||||
return active ?
|
||||
'border-[#222] bg-[#222] text-white'
|
||||
: 'border-[#d8d8d8] bg-[#f7f7f7] text-[#555] hover:bg-white';
|
||||
};
|
||||
|
||||
const SupportPortalShell = (props: Props) => {
|
||||
const { signOut } = useUserStore();
|
||||
const encodedWorkspaceCode = encodeURIComponent(props.workspaceCode);
|
||||
|
||||
const {
|
||||
workspaceName,
|
||||
currentTab,
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
aside,
|
||||
hideTabs,
|
||||
titleOnly,
|
||||
hideHeader,
|
||||
children,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#f5f5f5] px-6 py-8 text-[#222]">
|
||||
<div className="mx-auto flex max-w-6xl flex-col gap-4">
|
||||
{!hideHeader ? (
|
||||
<section className="rounded-[12px] border border-[#dcdcdc] bg-white px-6 py-5 shadow-[0_4px_16px_rgba(0,0,0,0.04)]">
|
||||
<div className="flex flex-col gap-4">
|
||||
{titleOnly ? (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h1 className="text-[20px] font-bold text-[#111]">{title}</h1>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void signOut()}
|
||||
className="rounded-[4px] border border-[#d0d0d0] bg-white px-4 py-2 text-sm font-medium text-[#333] transition hover:bg-[#f7f7f7]"
|
||||
>
|
||||
로그아웃
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-[20px] font-bold text-[#111]">{title}</h1>
|
||||
{description ? (
|
||||
<p className="mt-1 text-sm text-[#666]">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-[#666]">
|
||||
{eyebrow ? <span>{eyebrow}</span> : null}
|
||||
<span className="rounded border border-[#e2e2e2] bg-[#fafafa] px-2 py-1 text-xs font-medium text-[#555]">
|
||||
{workspaceName}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void signOut()}
|
||||
className="rounded-[4px] border border-[#d0d0d0] bg-white px-4 py-2 text-sm font-medium text-[#333] transition hover:bg-[#f7f7f7]"
|
||||
>
|
||||
로그아웃
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hideTabs ? (
|
||||
<div className="flex flex-wrap gap-2 border-b border-[#e5e5e5] pb-0">
|
||||
<Link
|
||||
href={`/support/${encodedWorkspaceCode}/list`}
|
||||
className={`rounded-t-[8px] border border-b-0 px-4 py-2 text-sm font-medium transition ${tabClassName(currentTab === 'list')}`}
|
||||
>
|
||||
문의목록
|
||||
</Link>
|
||||
<Link
|
||||
href={`/support/${encodedWorkspaceCode}/new`}
|
||||
className={`rounded-t-[8px] border border-b-0 px-4 py-2 text-sm font-medium transition ${tabClassName(currentTab === 'new')}`}
|
||||
>
|
||||
문의등록
|
||||
</Link>
|
||||
<span
|
||||
className={`rounded-t-[8px] border border-b-0 px-4 py-2 text-sm font-medium transition ${tabClassName(currentTab === 'detail')}`}
|
||||
>
|
||||
문의상세
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{aside ? <div>{aside}</div> : null}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SupportPortalShell;
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
const statusMap: Record<string, string> = {
|
||||
RECEIVED: 'bg-[#f3ead0] text-[#7d5b16]',
|
||||
PENDING_APPROVAL: 'bg-[#f7dfd3] text-[#8b4228]',
|
||||
APPROVED: 'bg-[#d6e9d8] text-[#1e5d39]',
|
||||
IN_PROGRESS: 'bg-[#d9e7f5] text-[#204f79]',
|
||||
RESOLVED: 'bg-[#dcede3] text-[#2f6f52]',
|
||||
CLOSED: 'bg-[#e6e3de] text-[#5f615d]',
|
||||
REJECTED: 'bg-[#f2d5d5] text-[#8c3030]',
|
||||
PENDING: 'bg-[#f7dfd3] text-[#8b4228]',
|
||||
NOT_REQUIRED: 'bg-[#e8ece8] text-[#506054]',
|
||||
NOT_LINKED: 'bg-[#ece7de] text-[#655a45]',
|
||||
LINKED: 'bg-[#dce7f8] text-[#325c93]',
|
||||
SYNCED: 'bg-[#dcefdc] text-[#336541]',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const SupportStatusBadge = ({ label, value }: Props) => {
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-2 rounded-full px-3 py-1 text-xs font-medium ${statusMap[value] ?? 'bg-[#ece8e1] text-[#5f5649]'}`}>
|
||||
<span className="uppercase tracking-[0.16em] opacity-70">{label}</span>
|
||||
<span>{value}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default SupportStatusBadge;
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
export type AISettingStore = {
|
||||
isPending: boolean;
|
||||
formId: string;
|
||||
setIsPending: (isPending: boolean) => void;
|
||||
isDirty: boolean;
|
||||
setIsDirty: (isDirty: boolean) => void;
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES 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 { AIIssue, AITemplate } from '@/entities/ai';
|
||||
|
||||
export const FORM_ID = 'ai-issue-form';
|
||||
|
||||
export const DEFAULT_FIELD_COLUMNS_KEYS = [
|
||||
'id',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'issues',
|
||||
];
|
||||
|
||||
export const GEMINI_PROVIDER = 'GEMINI';
|
||||
export const DEFAULT_MODEL_GEMINI = 'gemini-2.5-flash';
|
||||
export const DEFAULT_MODEL_GPT = 'gpt-4o';
|
||||
|
||||
export const AI_ISSUE_DEFAULT_VALUES: Partial<AIIssue> = {
|
||||
temperature: 0.5,
|
||||
prompt: '',
|
||||
isEnabled: true,
|
||||
dataReferenceAmount: 3,
|
||||
};
|
||||
|
||||
export const AI_TEMPLATE_DEFAULT_VALUES: Partial<AITemplate> = {
|
||||
temperature: 0.5,
|
||||
title: '',
|
||||
prompt: '',
|
||||
};
|
||||
|
||||
export const TEMPERATURE_CONFIG = {
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.1,
|
||||
labels: { min: 'Precise', max: 'Creative' },
|
||||
} as const;
|
||||
|
||||
export const DATA_REFERENCE_CONFIG = {
|
||||
min: 1,
|
||||
max: 5,
|
||||
step: 1,
|
||||
labels: { min: 'Low', max: 'High' },
|
||||
} as const;
|
||||
|
||||
export const PROVIDER_MODEL_CONFIG = {
|
||||
GEMINI: {
|
||||
defaultModel: 'gemini-2.5-flash',
|
||||
},
|
||||
DEFAULT: {
|
||||
defaultModel: 'gpt-4o',
|
||||
},
|
||||
} as const;
|
||||
@@ -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 const ISSUE_FORM_ID = 'issue-form';
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { createContext, useContext, useState } from 'react';
|
||||
|
||||
import type { PlaygroundInputItem } from '../playground-input-item.schema';
|
||||
|
||||
export interface AIPlaygroundContextValue {
|
||||
inputItems: PlaygroundInputItem[];
|
||||
updateInputItem: (id: string, item: PlaygroundInputItem) => void;
|
||||
deleteInputItem: (id: string) => void;
|
||||
addNewEditingItem: () => void;
|
||||
}
|
||||
|
||||
export const AIPlaygroundContext = createContext<AIPlaygroundContextValue>({
|
||||
inputItems: [],
|
||||
updateInputItem: () => {
|
||||
// Empty function for default context
|
||||
},
|
||||
deleteInputItem: () => {
|
||||
// Empty function for default context
|
||||
},
|
||||
addNewEditingItem: () => {
|
||||
// Empty function for default context
|
||||
},
|
||||
});
|
||||
|
||||
export const AIPlaygroundContextProvider: React.FC<{
|
||||
children: React.ReactNode;
|
||||
}> = ({ children }) => {
|
||||
const [inputItems, setInputItems] = useState<PlaygroundInputItem[]>([]);
|
||||
|
||||
const updateInputItem = (id: string, item: PlaygroundInputItem) => {
|
||||
setInputItems((prev) =>
|
||||
prev.map((prevItem) => (prevItem.id === id ? item : prevItem)),
|
||||
);
|
||||
};
|
||||
|
||||
const deleteInputItem = (id: string) => {
|
||||
setInputItems((prev) => prev.filter((item) => item.id !== id));
|
||||
};
|
||||
|
||||
const addNewEditingItem = () => {
|
||||
setInputItems((prev) => [
|
||||
{
|
||||
name: '',
|
||||
description: '',
|
||||
value: '',
|
||||
isEditing: true,
|
||||
id: crypto.randomUUID(),
|
||||
},
|
||||
...prev,
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<AIPlaygroundContext.Provider
|
||||
value={{
|
||||
deleteInputItem,
|
||||
inputItems,
|
||||
updateInputItem,
|
||||
addNewEditingItem,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AIPlaygroundContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAIPlayground = () => {
|
||||
return useContext(AIPlaygroundContext);
|
||||
};
|
||||
@@ -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 { useRouter } from 'next/router';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useOverlay } from '@toss/use-overlay';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { parseAsInteger, useQueryState } from 'nuqs';
|
||||
|
||||
import { toast } from '@ufb/react';
|
||||
|
||||
import { client, DeleteDialog } from '@/shared';
|
||||
|
||||
export const useAIIssueDelete = (projectId: number) => {
|
||||
const { t } = useTranslation();
|
||||
const [templateId] = useQueryState('templateId', parseAsInteger);
|
||||
const overlay = useOverlay();
|
||||
const router = useRouter();
|
||||
|
||||
const { mutate: deleteTemplate, isPending } = useMutation({
|
||||
mutationFn: async (body: { templateId: number }) => {
|
||||
const { data } = await client.delete({
|
||||
path: '/api/admin/projects/{projectId}/ai/issueTemplates/{templateId}',
|
||||
pathParams: { projectId, templateId: body.templateId },
|
||||
});
|
||||
return data;
|
||||
},
|
||||
onSuccess() {
|
||||
router.back();
|
||||
toast.success(t('v2.toast.success'));
|
||||
},
|
||||
onError(error) {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const openDeleteTemplateConfirm = () => {
|
||||
if (!templateId) return;
|
||||
overlay.open(({ close, isOpen }) => (
|
||||
<DeleteDialog
|
||||
close={close}
|
||||
isOpen={isOpen}
|
||||
onClickDelete={() => {
|
||||
deleteTemplate({ templateId });
|
||||
}}
|
||||
/>
|
||||
));
|
||||
};
|
||||
return {
|
||||
templateId,
|
||||
openDeleteTemplateConfirm,
|
||||
isPending,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES 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 { useRouter } from 'next/router';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { toast } from '@ufb/react';
|
||||
|
||||
import {
|
||||
client,
|
||||
useOAIMutation,
|
||||
useOAIQuery,
|
||||
useWarnIfUnsavedChanges,
|
||||
} from '@/shared';
|
||||
import { aiIssueSchema } from '@/entities/ai';
|
||||
import type { AIIssue } from '@/entities/ai';
|
||||
|
||||
import { AI_ISSUE_DEFAULT_VALUES, PROVIDER_MODEL_CONFIG } from '../constants';
|
||||
import { useAIIssueFormStore } from '../stores/ai-issue-form.store';
|
||||
|
||||
export const useAIIssueForm = (projectId: number) => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const templateId = router.query.templateId ? +router.query.templateId : null;
|
||||
const { setIsPending, setIsDirty } = useAIIssueFormStore();
|
||||
|
||||
const methods = useForm<AIIssue>({
|
||||
defaultValues: AI_ISSUE_DEFAULT_VALUES,
|
||||
resolver: zodResolver(aiIssueSchema),
|
||||
});
|
||||
|
||||
const { formState } = methods;
|
||||
|
||||
const { data: channelData } = useOAIQuery({
|
||||
path: '/api/admin/projects/{projectId}/channels',
|
||||
variables: { projectId },
|
||||
});
|
||||
|
||||
const { data: templateData, refetch } = useOAIQuery({
|
||||
path: '/api/admin/projects/{projectId}/ai/issueTemplates',
|
||||
variables: { projectId },
|
||||
});
|
||||
|
||||
const { data: integrationData } = useOAIQuery({
|
||||
path: '/api/admin/projects/{projectId}/ai/integrations',
|
||||
variables: { projectId },
|
||||
queryOptions: { enabled: !!templateId },
|
||||
});
|
||||
|
||||
const { data: modelData } = useOAIQuery({
|
||||
path: '/api/admin/projects/{projectId}/ai/integrations/models',
|
||||
variables: { projectId },
|
||||
});
|
||||
|
||||
const { mutate: createTemplate, isPending } = useOAIMutation({
|
||||
method: 'post',
|
||||
path: '/api/admin/projects/{projectId}/ai/issueTemplates/new',
|
||||
pathParams: { projectId },
|
||||
queryOptions: {
|
||||
async onSuccess(data) {
|
||||
toast.success(t('v2.toast.success'));
|
||||
await router.replace({
|
||||
pathname: router.pathname,
|
||||
query: { ...router.query, templateId: data?.id },
|
||||
});
|
||||
await refetch();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: updateTemplate } = useMutation({
|
||||
mutationFn: async (body: AIIssue & { templateId: number }) => {
|
||||
const { data } = await client.put({
|
||||
path: '/api/admin/projects/{projectId}/ai/issueTemplates/{templateId}',
|
||||
pathParams: { projectId, templateId: body.templateId },
|
||||
body,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
async onSuccess() {
|
||||
toast.success(t('v2.toast.success'));
|
||||
await refetch();
|
||||
},
|
||||
onError(error) {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: deleteTemplate } = useMutation({
|
||||
mutationFn: async (body: { templateId: number }) => {
|
||||
const { data } = await client.delete({
|
||||
path: '/api/admin/projects/{projectId}/ai/issueTemplates/{templateId}',
|
||||
pathParams: { projectId, templateId: body.templateId },
|
||||
});
|
||||
return data;
|
||||
},
|
||||
onSuccess() {
|
||||
toast.success(t('v2.toast.success'));
|
||||
router.back();
|
||||
},
|
||||
onError(error) {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setIsPending(isPending);
|
||||
}, [isPending, setIsPending]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsDirty(formState.isDirty);
|
||||
}, [formState.isDirty, setIsDirty]);
|
||||
|
||||
const model = methods.watch('model');
|
||||
useEffect(() => {
|
||||
if (!modelData) return;
|
||||
if (!modelData.models.some((m) => m.id === model)) {
|
||||
methods.setError('model', {
|
||||
type: 'manual',
|
||||
message: 'Model is not available',
|
||||
});
|
||||
} else {
|
||||
methods.clearErrors('model');
|
||||
}
|
||||
}, [modelData, model]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!templateData) return;
|
||||
|
||||
const original = templateData.find((v) => v.id === templateId);
|
||||
if (original) {
|
||||
methods.reset({
|
||||
...AI_ISSUE_DEFAULT_VALUES,
|
||||
...original,
|
||||
} as AIIssue);
|
||||
if (!modelData?.models.some((model) => model.id === original.model)) {
|
||||
methods.setError('model', {
|
||||
type: 'manual',
|
||||
message: 'Model is not available',
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultModel =
|
||||
integrationData?.provider === 'GEMINI' ?
|
||||
PROVIDER_MODEL_CONFIG.GEMINI.defaultModel
|
||||
: PROVIDER_MODEL_CONFIG.DEFAULT.defaultModel;
|
||||
|
||||
methods.reset({
|
||||
...AI_ISSUE_DEFAULT_VALUES,
|
||||
model: defaultModel,
|
||||
});
|
||||
}, [templateData, templateId, integrationData, methods]);
|
||||
|
||||
useWarnIfUnsavedChanges(
|
||||
methods.formState.isDirty,
|
||||
'/settings?menu=generative-ai&subMenu=ai-issue-form',
|
||||
);
|
||||
const handleFormSubmit = (values: AIIssue) => {
|
||||
if (
|
||||
templateData?.some(
|
||||
(v) => v.channelId === values.channelId && v.id !== templateId,
|
||||
)
|
||||
) {
|
||||
methods.setError('channelId', {
|
||||
type: 'manual',
|
||||
message: 'A channel with this ID already exists.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!modelData?.models.some((model) => model.id === values.model)) {
|
||||
methods.setError('model', {
|
||||
type: 'manual',
|
||||
message: 'Model is not available',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (templateId) updateTemplate({ ...values, templateId });
|
||||
else createTemplate(values);
|
||||
};
|
||||
|
||||
return {
|
||||
channelData,
|
||||
templateData,
|
||||
integrationData,
|
||||
modelData,
|
||||
templateId,
|
||||
refetch,
|
||||
methods,
|
||||
createTemplate,
|
||||
updateTemplate,
|
||||
deleteTemplate,
|
||||
handleFormSubmit,
|
||||
};
|
||||
};
|
||||
@@ -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 { useCallback, useState } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
|
||||
import { toast } from '@ufb/react';
|
||||
|
||||
import { useOAIMutation } from '@/shared';
|
||||
import type { AIIssue } from '@/entities/ai';
|
||||
|
||||
import type { PlaygroundInputItem } from '../playground-input-item.schema';
|
||||
|
||||
export const useAIIssueTest = () => {
|
||||
const [result, setResult] = useState<string[] | undefined>();
|
||||
const { getValues, watch } = useFormContext<AIIssue>();
|
||||
const router = useRouter();
|
||||
const projectId = Number(router.query.projectId);
|
||||
|
||||
const { mutateAsync, isPending } = useOAIMutation({
|
||||
method: 'post',
|
||||
path: '/api/admin/projects/{projectId}/ai/issueRecommend/playground/test',
|
||||
pathParams: { projectId },
|
||||
queryOptions: {
|
||||
onSuccess: (data) => {
|
||||
setResult(data?.result);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const executeTest = useCallback(
|
||||
(inputItems: PlaygroundInputItem[]) => {
|
||||
const {
|
||||
model,
|
||||
temperature,
|
||||
channelId,
|
||||
dataReferenceAmount,
|
||||
prompt,
|
||||
targetFieldKeys,
|
||||
} = getValues();
|
||||
|
||||
toast.promise(
|
||||
mutateAsync({
|
||||
model,
|
||||
temperature,
|
||||
channelId,
|
||||
dataReferenceAmount,
|
||||
targetFieldKeys,
|
||||
templatePrompt: prompt,
|
||||
temporaryFields: inputItems,
|
||||
}),
|
||||
{
|
||||
success: () => '성공',
|
||||
loading: '로딩중',
|
||||
},
|
||||
);
|
||||
},
|
||||
[getValues, mutateAsync],
|
||||
);
|
||||
|
||||
const isTestDisabled = watch('model') === '';
|
||||
|
||||
return {
|
||||
result,
|
||||
isPending,
|
||||
executeTest,
|
||||
isTestDisabled,
|
||||
};
|
||||
};
|
||||
@@ -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 React from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useOverlay } from '@toss/use-overlay';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { parseAsInteger, useQueryState } from 'nuqs';
|
||||
|
||||
import { toast } from '@ufb/react';
|
||||
|
||||
import { client, DeleteDialog } from '@/shared';
|
||||
|
||||
interface UseAITemplateDeleteReturn {
|
||||
templateId: number | null;
|
||||
isPending: boolean;
|
||||
openDeleteConfirmDialog: () => void;
|
||||
}
|
||||
|
||||
export const useAITemplateDelete = (
|
||||
projectId: number,
|
||||
): UseAITemplateDeleteReturn => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const overlay = useOverlay();
|
||||
const [templateId] = useQueryState('templateId', parseAsInteger);
|
||||
|
||||
const { mutate: deleteTemplate, isPending } = useMutation({
|
||||
mutationFn: async (body: { templateId: number }) => {
|
||||
const { data } = await client.delete({
|
||||
path: '/api/admin/projects/{projectId}/ai/fieldTemplates/{templateId}',
|
||||
pathParams: { projectId, templateId: body.templateId },
|
||||
});
|
||||
return data;
|
||||
},
|
||||
onSuccess() {
|
||||
router.back();
|
||||
toast.success(t('v2.toast.success'));
|
||||
},
|
||||
onError(error) {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const openDeleteConfirmDialog = () => {
|
||||
if (!templateId) return;
|
||||
|
||||
overlay.open(({ close, isOpen }) => {
|
||||
return (
|
||||
<DeleteDialog
|
||||
close={close}
|
||||
isOpen={isOpen}
|
||||
onClickDelete={() => {
|
||||
deleteTemplate({ templateId });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
templateId,
|
||||
isPending,
|
||||
openDeleteConfirmDialog,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES 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 { useRouter } from 'next/router';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { toast } from '@ufb/react';
|
||||
|
||||
import { client, useOAIMutation, useOAIQuery } from '@/shared';
|
||||
import { aiTemplateSchema } from '@/entities/ai';
|
||||
import type { AITemplate } from '@/entities/ai';
|
||||
|
||||
import {
|
||||
AI_TEMPLATE_DEFAULT_VALUES,
|
||||
PROVIDER_MODEL_CONFIG,
|
||||
} from '../constants';
|
||||
import { useAITemplateFormStore } from '../stores/ai-template-form.store';
|
||||
|
||||
export const useAITemplateForm = (projectId: number) => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const templateId = router.query.templateId ? +router.query.templateId : null;
|
||||
const { setIsPending, setIsDirty } = useAITemplateFormStore();
|
||||
|
||||
const methods = useForm<AITemplate>({
|
||||
defaultValues: AI_TEMPLATE_DEFAULT_VALUES,
|
||||
resolver: zodResolver(aiTemplateSchema),
|
||||
});
|
||||
|
||||
const { formState } = methods;
|
||||
|
||||
// Data fetching
|
||||
const { data: templateData, refetch } = useOAIQuery({
|
||||
path: '/api/admin/projects/{projectId}/ai/fieldTemplates',
|
||||
variables: { projectId },
|
||||
});
|
||||
|
||||
const { data: integrationData } = useOAIQuery({
|
||||
path: '/api/admin/projects/{projectId}/ai/integrations',
|
||||
variables: { projectId },
|
||||
queryOptions: { enabled: !!templateId },
|
||||
});
|
||||
|
||||
const { data: modelData } = useOAIQuery({
|
||||
path: '/api/admin/projects/{projectId}/ai/integrations/models',
|
||||
variables: { projectId },
|
||||
});
|
||||
|
||||
const { mutate: createTemplate, isPending } = useOAIMutation({
|
||||
method: 'post',
|
||||
path: '/api/admin/projects/{projectId}/ai/fieldTemplates/new',
|
||||
pathParams: { projectId },
|
||||
queryOptions: {
|
||||
async onSuccess(data) {
|
||||
toast.success(t('v2.toast.success'));
|
||||
await router.replace({
|
||||
pathname: router.pathname,
|
||||
query: { ...router.query, templateId: data?.id },
|
||||
});
|
||||
await refetch();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: updateTemplate } = useMutation({
|
||||
mutationFn: async (body: AITemplate & { templateId: number }) => {
|
||||
const { data } = await client.put({
|
||||
path: '/api/admin/projects/{projectId}/ai/fieldTemplates/{templateId}',
|
||||
pathParams: { projectId, templateId: body.templateId },
|
||||
body,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
async onSuccess() {
|
||||
await refetch();
|
||||
toast.success(t('v2.toast.success'));
|
||||
},
|
||||
onError(error) {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
// Effects
|
||||
useEffect(() => {
|
||||
setIsPending(isPending);
|
||||
}, [isPending, setIsPending]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsDirty(formState.isDirty);
|
||||
}, [formState.isDirty, setIsDirty]);
|
||||
|
||||
const model = methods.watch('model');
|
||||
useEffect(() => {
|
||||
if (!modelData) return;
|
||||
if (!modelData.models.some((m) => m.id === model)) {
|
||||
methods.setError('model', {
|
||||
type: 'manual',
|
||||
message: 'Model is not available',
|
||||
});
|
||||
} else {
|
||||
methods.clearErrors('model');
|
||||
}
|
||||
}, [modelData, model]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!templateData) return;
|
||||
|
||||
const original = templateData.find((v) => v.id === templateId);
|
||||
if (original) {
|
||||
methods.reset({
|
||||
...AI_TEMPLATE_DEFAULT_VALUES,
|
||||
...original,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultModel =
|
||||
integrationData?.provider === 'GEMINI' ?
|
||||
PROVIDER_MODEL_CONFIG.GEMINI.defaultModel
|
||||
: PROVIDER_MODEL_CONFIG.DEFAULT.defaultModel;
|
||||
|
||||
methods.reset({
|
||||
...AI_TEMPLATE_DEFAULT_VALUES,
|
||||
model: defaultModel,
|
||||
});
|
||||
}, [templateData, templateId, integrationData, methods]);
|
||||
|
||||
// Submit handler
|
||||
const handleFormSubmit = (values: AITemplate) => {
|
||||
if (
|
||||
templateData?.some((v) => v.title === values.title && v.id !== templateId)
|
||||
) {
|
||||
methods.setError('title', {
|
||||
type: 'manual',
|
||||
message: 'A template with this title already exists.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!modelData?.models.some((model) => model.id === values.model)) {
|
||||
methods.setError('model', {
|
||||
type: 'manual',
|
||||
message: 'Model is not available',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (templateId) {
|
||||
updateTemplate({ ...values, templateId });
|
||||
} else {
|
||||
createTemplate(values);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
methods,
|
||||
templateId,
|
||||
templateData,
|
||||
integrationData,
|
||||
modelData,
|
||||
handleFormSubmit,
|
||||
refetch,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
|
||||
import { toast } from '@ufb/react';
|
||||
|
||||
import { useOAIMutation } from '@/shared';
|
||||
import type { AITemplate } from '@/entities/ai';
|
||||
|
||||
import type { PlaygroundInputItem } from '../playground-input-item.schema';
|
||||
|
||||
export const useAITemplateTest = (projectId: number) => {
|
||||
const [result, setResult] = useState('');
|
||||
const { getValues, watch } = useFormContext<AITemplate>();
|
||||
|
||||
const { mutateAsync, isPending } = useOAIMutation({
|
||||
method: 'post',
|
||||
path: '/api/admin/projects/{projectId}/ai/playground/test',
|
||||
pathParams: { projectId },
|
||||
queryOptions: {
|
||||
onSuccess: (data) => {
|
||||
setResult(data?.result ?? '');
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const executeTest = useCallback(
|
||||
(inputItems: PlaygroundInputItem[]) => {
|
||||
const { model, temperature, prompt } = getValues();
|
||||
toast.promise(
|
||||
mutateAsync({
|
||||
model,
|
||||
temperature,
|
||||
templatePrompt: prompt,
|
||||
temporaryFields: inputItems,
|
||||
}),
|
||||
{
|
||||
success: () => '성공',
|
||||
loading: '로딩중',
|
||||
},
|
||||
);
|
||||
},
|
||||
[getValues, mutateAsync],
|
||||
);
|
||||
|
||||
const isTestDisabled = watch('prompt') === '' || watch('model') === '';
|
||||
|
||||
return {
|
||||
result,
|
||||
isPending,
|
||||
executeTest,
|
||||
isTestDisabled,
|
||||
};
|
||||
};
|
||||
@@ -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,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 { z } from 'zod';
|
||||
|
||||
export const playgroundInputItemSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string().trim().min(1).max(20),
|
||||
description: z.string().trim().max(50),
|
||||
value: z.string().min(1),
|
||||
isEditing: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type PlaygroundInputItem = z.infer<typeof playgroundInputItemSchema>;
|
||||
@@ -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 { create } from 'zustand';
|
||||
|
||||
import type { AISettingStore } from '../ai-setting-form.type';
|
||||
import { ISSUE_FORM_ID } from '../contexts/ai-issue-form.context';
|
||||
|
||||
export const useAIIssueFormStore = create<AISettingStore>((set) => ({
|
||||
formId: ISSUE_FORM_ID,
|
||||
isPending: false,
|
||||
setIsPending: (isPending) => set({ isPending }),
|
||||
isDirty: false,
|
||||
setIsDirty: (isDirty) => set({ isDirty }),
|
||||
}));
|
||||
@@ -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 { create } from 'zustand';
|
||||
|
||||
import type { AISettingStore } from '../ai-setting-form.type';
|
||||
|
||||
export const useAITemplateFormStore = create<AISettingStore>((set) => ({
|
||||
formId: 'ai-template-form',
|
||||
isPending: false,
|
||||
setIsPending: (isPending) => set({ isPending }),
|
||||
isDirty: false,
|
||||
setIsDirty: (isDirty) => set({ isDirty }),
|
||||
}));
|
||||
Whitespace-only changes.
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES 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 { FormProvider } from 'react-hook-form';
|
||||
|
||||
import { Button, Divider, FormField, InputField, Label } from '@ufb/react';
|
||||
|
||||
import {
|
||||
CardDescription,
|
||||
CardTitle,
|
||||
Slider,
|
||||
usePermissions,
|
||||
useWarnIfUnsavedChanges,
|
||||
} from '@/shared';
|
||||
import { FormInput, FormSelect, FormTextarea } from '@/shared/ui/form-inputs';
|
||||
|
||||
import { TEMPERATURE_CONFIG } from '../constants';
|
||||
import { useAITemplateDelete } from '../hooks/use-ai-template-deletion';
|
||||
import { useAITemplateForm } from '../hooks/use-ai-template-form';
|
||||
import { useAITemplateTest } from '../hooks/use-ai-template-test';
|
||||
import { useAITemplateFormStore } from '../stores/ai-template-form.store';
|
||||
import AiFormTemplate from './ai-form-template.ui';
|
||||
import AiPlaygroundTemplate from './ai-playground-template.ui';
|
||||
|
||||
export const AIFieldTemplateForm = ({ projectId }: { projectId: number }) => {
|
||||
const { t } = useTranslation();
|
||||
const { methods, modelData, handleFormSubmit } = useAITemplateForm(projectId);
|
||||
|
||||
const { setValue, watch, handleSubmit } = methods;
|
||||
const { formId } = useAITemplateFormStore();
|
||||
|
||||
useWarnIfUnsavedChanges(
|
||||
methods.formState.isDirty,
|
||||
'/settings?menu=generative-ai&subMenu=field-template-form',
|
||||
);
|
||||
|
||||
return (
|
||||
<AiFormTemplate
|
||||
methods={methods}
|
||||
description={t('v2.description.ai-field-template-form')}
|
||||
playground={<AIFieldPlayground projectId={projectId} />}
|
||||
>
|
||||
<FormProvider {...methods}>
|
||||
<form
|
||||
id={formId}
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={handleSubmit(handleFormSubmit)}
|
||||
>
|
||||
<FormField
|
||||
control={methods.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormInput
|
||||
label="Title"
|
||||
{...field}
|
||||
placeholder={t('v2.placeholder.text')}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={methods.control}
|
||||
name="prompt"
|
||||
render={({ field }) => (
|
||||
<FormTextarea
|
||||
label="Prompt"
|
||||
placeholder={t('v2.placeholder.ai-field-template-prompt')}
|
||||
{...field}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Divider variant="subtle" />
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<CardTitle size="lg">Advanced Configuration</CardTitle>
|
||||
<CardDescription>
|
||||
{t('v2.description.ai-field-template-advanced-configuration')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<FormField
|
||||
control={methods.control}
|
||||
name="model"
|
||||
render={({ field }) => (
|
||||
<FormSelect
|
||||
options={
|
||||
modelData?.models.map(({ id }) => ({
|
||||
value: id,
|
||||
label: id,
|
||||
})) ?? []
|
||||
}
|
||||
label="Model"
|
||||
placeholder="Select a model"
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<InputField>
|
||||
<Label>Temperature</Label>
|
||||
<div className="border-neutral-tertiary rounded-8 flex gap-4 border p-6">
|
||||
<div>Precise</div>
|
||||
<Slider
|
||||
min={TEMPERATURE_CONFIG.min}
|
||||
max={TEMPERATURE_CONFIG.max}
|
||||
step={TEMPERATURE_CONFIG.step}
|
||||
value={[watch('temperature')]}
|
||||
onValueChange={(value) => {
|
||||
setValue('temperature', value[0] ?? 0, {
|
||||
shouldDirty: true,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<div>Creative</div>
|
||||
</div>
|
||||
</InputField>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</AiFormTemplate>
|
||||
);
|
||||
};
|
||||
|
||||
const AIFieldPlayground = ({ projectId }: { projectId: number }) => {
|
||||
const { t } = useTranslation();
|
||||
const aiTest = useAITemplateTest(projectId);
|
||||
return (
|
||||
<AiPlaygroundTemplate
|
||||
description={t('v2.description.ai-field-template-playground')}
|
||||
onTestAI={aiTest.executeTest}
|
||||
result={aiTest.result}
|
||||
isPending={aiTest.isPending}
|
||||
isDisabled={aiTest.isTestDisabled}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const AITemplateFormButton = ({ projectId }: { projectId: number }) => {
|
||||
const { t } = useTranslation();
|
||||
const { formId, isPending, isDirty } = useAITemplateFormStore();
|
||||
const {
|
||||
templateId,
|
||||
isPending: isDeletePending,
|
||||
openDeleteConfirmDialog,
|
||||
} = useAITemplateDelete(projectId);
|
||||
const perms = usePermissions(projectId);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="!text-tint-red"
|
||||
onClick={openDeleteConfirmDialog}
|
||||
disabled={!templateId || !perms.includes('project_genai_update')}
|
||||
loading={isDeletePending}
|
||||
>
|
||||
{t('v2.button.name.delete', { name: 'Template' })}
|
||||
</Button>
|
||||
<Button
|
||||
form={formId}
|
||||
type="submit"
|
||||
loading={isPending}
|
||||
disabled={!isDirty || !perms.includes('project_genai_update')}
|
||||
>
|
||||
{t('v2.button.save')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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 { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { Icon } from '@ufb/react';
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
GRADIENT_CSS,
|
||||
SettingAlert,
|
||||
useOAIQuery,
|
||||
} from '@/shared';
|
||||
|
||||
import { AIGenerateIcon } from '@/assets';
|
||||
|
||||
export const AIFieldTemplateSetting = ({
|
||||
onClick,
|
||||
projectId,
|
||||
}: {
|
||||
onClick: (templateId?: number) => void;
|
||||
projectId: number;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { data } = useOAIQuery({
|
||||
path: '/api/admin/projects/{projectId}/ai/fieldTemplates',
|
||||
variables: { projectId },
|
||||
});
|
||||
const { data: modelData } = useOAIQuery({
|
||||
path: '/api/admin/projects/{projectId}/ai/integrations/models',
|
||||
variables: { projectId },
|
||||
});
|
||||
const [templates, setTemplates] = useState<
|
||||
{ id: number; title: string; prompt: string; isError?: boolean }[]
|
||||
>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
setTemplates(
|
||||
data.map((template) => ({
|
||||
id: template.id,
|
||||
title: template.title,
|
||||
prompt: template.prompt,
|
||||
isError:
|
||||
modelData ?
|
||||
!modelData.models.some((model) => model.id === template.model)
|
||||
: false,
|
||||
})),
|
||||
);
|
||||
}, [data, modelData]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingAlert description={t('help-card.ai-field-template')} />
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<TemplateCard
|
||||
type="create"
|
||||
title="Create New"
|
||||
onClick={() => onClick()}
|
||||
/>
|
||||
{templates.map((template) => (
|
||||
<TemplateCard
|
||||
key={template.id}
|
||||
type="update"
|
||||
onClick={() => onClick(template.id)}
|
||||
{...template}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const TemplateCard = (props: {
|
||||
title: string;
|
||||
prompt?: string;
|
||||
type: 'create' | 'update';
|
||||
onClick?: () => void;
|
||||
isError?: boolean;
|
||||
}) => {
|
||||
const { title, prompt, type, onClick, isError } = props;
|
||||
return (
|
||||
<Card
|
||||
onClick={onClick}
|
||||
className="!rounded-24 min-h-60 cursor-pointer transition-shadow duration-300 hover:shadow-lg"
|
||||
>
|
||||
<CardBody className="flex h-full flex-col justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
{type === 'create' && (
|
||||
<Icon name="RiAddCircleFill" className="text-neutral-tertiary" />
|
||||
)}
|
||||
{type === 'update' && <AIGenerateIcon />}
|
||||
<h4 className="text-title-h4">
|
||||
{title}
|
||||
{isError && (
|
||||
<Icon
|
||||
name="RiErrorWarningFill"
|
||||
className="ml-1 text-red-500"
|
||||
size={16}
|
||||
/>
|
||||
)}
|
||||
</h4>
|
||||
</div>
|
||||
{prompt && (
|
||||
<div>
|
||||
<p className="text-small-normal">Prompt Preview</p>
|
||||
<div className="bg-neutral-tertiary rounded-12 relative p-3">
|
||||
<p className="text-small-normal line-clamp-2 break-all">
|
||||
{prompt}
|
||||
</p>
|
||||
<div
|
||||
className="rounded-12 absolute inset-0"
|
||||
style={GRADIENT_CSS.fadeOut}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -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 { useState } from 'react';
|
||||
import type { UseFormReturn } from 'react-hook-form';
|
||||
import { FormProvider } from 'react-hook-form';
|
||||
|
||||
import { Button, Icon } from '@ufb/react';
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/shared';
|
||||
|
||||
interface Props<T extends Record<string, unknown>>
|
||||
extends React.PropsWithChildren {
|
||||
methods: UseFormReturn<T>;
|
||||
playground?: React.ReactNode;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const AiFormTemplate = <T extends Record<string, unknown>>(props: Props<T>) => {
|
||||
const { methods, children, playground, description } = props;
|
||||
const [isShrunk, setIsShrunk] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 gap-4">
|
||||
<FormProvider {...methods}>
|
||||
{!isShrunk ?
|
||||
<Card className="flex-1 overflow-auto border" size="lg">
|
||||
<CardHeader
|
||||
action={
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsShrunk(!isShrunk)}
|
||||
>
|
||||
<Icon name="RiExpandLeftFill" />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<CardTitle>Configuration</CardTitle>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardBody className="flex flex-col gap-4">{children}</CardBody>
|
||||
</Card>
|
||||
: <Button
|
||||
variant="outline"
|
||||
onClick={() => setIsShrunk(!isShrunk)}
|
||||
className="mb-4"
|
||||
>
|
||||
<Icon name="RiExpandRightFill" />
|
||||
</Button>
|
||||
}
|
||||
{playground}
|
||||
</FormProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AiFormTemplate;
|
||||
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES 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 from 'react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
|
||||
import { FormField, InputField, Label, Switch } from '@ufb/react';
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
DescriptionTooltip,
|
||||
Slider,
|
||||
} from '@/shared';
|
||||
import {
|
||||
FormMultiSelect,
|
||||
FormSelect,
|
||||
FormTextarea,
|
||||
} from '@/shared/ui/form-inputs';
|
||||
import type { AIIssue } from '@/entities/ai';
|
||||
|
||||
import { DATA_REFERENCE_CONFIG, TEMPERATURE_CONFIG } from '../constants';
|
||||
|
||||
interface ChannelSelectProps {
|
||||
channels: { id: number; name: string }[] | undefined;
|
||||
}
|
||||
|
||||
export const ChannelSelect: React.FC<ChannelSelectProps> = ({ channels }) => {
|
||||
const { setValue, control } = useFormContext<AIIssue>();
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name="channelId"
|
||||
render={({ field }) => (
|
||||
<FormSelect
|
||||
options={
|
||||
channels?.map(({ id, name }) => ({
|
||||
value: String(id),
|
||||
label: name,
|
||||
})) ?? []
|
||||
}
|
||||
label="Channel"
|
||||
placeholder="Select a channel"
|
||||
onChange={(value) => {
|
||||
if (!value) return;
|
||||
field.onChange(+value);
|
||||
setValue('targetFieldKeys', []);
|
||||
}}
|
||||
value={field.value ? String(field.value) : ''}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface FieldSelectProps {
|
||||
fields: { name: string; key: string }[] | undefined;
|
||||
}
|
||||
|
||||
export const FieldSelect: React.FC<FieldSelectProps> = ({ fields }) => {
|
||||
const { control } = useFormContext<AIIssue>();
|
||||
|
||||
return (
|
||||
<FormField
|
||||
name="targetFieldKeys"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormMultiSelect
|
||||
options={
|
||||
fields
|
||||
?.filter((v) => v.key !== 'issues')
|
||||
.map(({ name, key }) => ({ value: key, label: name })) ?? []
|
||||
}
|
||||
label="Target Field"
|
||||
placeholder="Select a field"
|
||||
{...field}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const PromptField: React.FC = () => {
|
||||
const { control } = useFormContext<AIIssue>();
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<FormField
|
||||
name="prompt"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormTextarea
|
||||
label="Prompt"
|
||||
{...field}
|
||||
placeholder={t('v2.placeholder.ai-field-reccommendation-prompt')}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const EnableTemplateCard: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { control } = useFormContext<AIIssue>();
|
||||
|
||||
return (
|
||||
<Card size="sm">
|
||||
<CardHeader
|
||||
action={
|
||||
<FormField
|
||||
control={control}
|
||||
name="isEnabled"
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
defaultChecked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<CardTitle>Enable/Disable</CardTitle>
|
||||
<CardDescription>
|
||||
{t('v2.description.ai-issue-recommendation-enable')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
interface ModelSelectProps {
|
||||
models: { id: string }[] | undefined;
|
||||
}
|
||||
|
||||
export const ModelSelect: React.FC<ModelSelectProps> = ({ models }) => {
|
||||
const { control } = useFormContext<AIIssue>();
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name="model"
|
||||
render={({ field }) => (
|
||||
<FormSelect
|
||||
options={
|
||||
models?.map(({ id }) => ({
|
||||
value: id,
|
||||
label: id,
|
||||
})) ?? []
|
||||
}
|
||||
label="Model"
|
||||
placeholder="Select a model"
|
||||
onChange={field.onChange}
|
||||
value={field.value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const TemperatureSlider: React.FC = () => {
|
||||
const { setValue, watch } = useFormContext<AIIssue>();
|
||||
|
||||
return (
|
||||
<InputField>
|
||||
<Label>Temperature</Label>
|
||||
<div className="border-neutral-tertiary rounded-8 flex gap-4 border p-6">
|
||||
<div>{TEMPERATURE_CONFIG.labels.min}</div>
|
||||
<Slider
|
||||
min={TEMPERATURE_CONFIG.min}
|
||||
max={TEMPERATURE_CONFIG.max}
|
||||
step={TEMPERATURE_CONFIG.step}
|
||||
value={[watch('temperature')]}
|
||||
onValueChange={(value) => {
|
||||
setValue('temperature', value[0] ?? 0, {
|
||||
shouldDirty: true,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<div>{TEMPERATURE_CONFIG.labels.max}</div>
|
||||
</div>
|
||||
</InputField>
|
||||
);
|
||||
};
|
||||
|
||||
export const DataReferenceSlider: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { setValue, watch } = useFormContext<AIIssue>();
|
||||
|
||||
return (
|
||||
<InputField>
|
||||
<Label>
|
||||
Data Reference Amount
|
||||
<DescriptionTooltip
|
||||
description={t(
|
||||
'v2.description.ai-issue-recommendation-data-reference-amount',
|
||||
)}
|
||||
/>
|
||||
</Label>
|
||||
<div className="border-neutral-tertiary rounded-8 flex gap-4 border p-6">
|
||||
<div>{DATA_REFERENCE_CONFIG.labels.min}</div>
|
||||
<Slider
|
||||
min={DATA_REFERENCE_CONFIG.min}
|
||||
max={DATA_REFERENCE_CONFIG.max}
|
||||
step={DATA_REFERENCE_CONFIG.step}
|
||||
value={[watch('dataReferenceAmount')]}
|
||||
onValueChange={(value) => {
|
||||
setValue('dataReferenceAmount', value[0] ?? 3, {
|
||||
shouldDirty: true,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<div>{DATA_REFERENCE_CONFIG.labels.max}</div>
|
||||
</div>
|
||||
</InputField>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES 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 { Button, Divider } from '@ufb/react';
|
||||
|
||||
import {
|
||||
CardDescription,
|
||||
CardTitle,
|
||||
useOAIQuery,
|
||||
usePermissions,
|
||||
} from '@/shared';
|
||||
|
||||
import { useAIIssueDelete } from '../hooks/use-ai-issue-delete';
|
||||
import { useAIIssueForm } from '../hooks/use-ai-issue-form';
|
||||
import { useAIIssueTest } from '../hooks/use-ai-issue-test';
|
||||
import { useAIIssueFormStore } from '../stores/ai-issue-form.store';
|
||||
import AiFormTemplate from './ai-form-template.ui';
|
||||
import {
|
||||
ChannelSelect,
|
||||
DataReferenceSlider,
|
||||
EnableTemplateCard,
|
||||
FieldSelect,
|
||||
ModelSelect,
|
||||
PromptField,
|
||||
TemperatureSlider,
|
||||
} from './ai-issue-form-fields.ui';
|
||||
import AiPlaygroundTemplate from './ai-playground-template.ui';
|
||||
|
||||
export const AIIssueForm = ({ projectId }: { projectId: number }) => {
|
||||
const { t } = useTranslation();
|
||||
const { channelData, modelData, methods, handleFormSubmit } =
|
||||
useAIIssueForm(projectId);
|
||||
const { formId } = useAIIssueFormStore();
|
||||
const { watch, handleSubmit } = methods;
|
||||
|
||||
const { data: channelDetailData } = useOAIQuery({
|
||||
path: '/api/admin/projects/{projectId}/channels/{channelId}',
|
||||
variables: { projectId, channelId: watch('channelId') },
|
||||
queryOptions: { enabled: !!watch('channelId') },
|
||||
});
|
||||
|
||||
return (
|
||||
<AiFormTemplate
|
||||
methods={methods}
|
||||
description={t('v2.description.ai-issue-recommendation-form')}
|
||||
playground={<AIIssuePlayground />}
|
||||
>
|
||||
<form
|
||||
id={formId}
|
||||
className="flex flex-col gap-3"
|
||||
onSubmit={handleSubmit(handleFormSubmit)}
|
||||
>
|
||||
<ChannelSelect channels={channelData?.items} />
|
||||
<FieldSelect fields={channelDetailData?.fields} />
|
||||
<PromptField />
|
||||
<EnableTemplateCard />
|
||||
<Divider variant="subtle" />
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<CardTitle size="lg">Advanced Configuration</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
'v2.description.ai-issue-recommendation-advanced-configuration',
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<ModelSelect models={modelData?.models} />
|
||||
<TemperatureSlider />
|
||||
<DataReferenceSlider />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</AiFormTemplate>
|
||||
);
|
||||
};
|
||||
|
||||
const AIIssuePlayground = () => {
|
||||
const { t } = useTranslation();
|
||||
const aiTest = useAIIssueTest();
|
||||
|
||||
return (
|
||||
<AiPlaygroundTemplate
|
||||
description={t('v2.description.ai-issue-recommendation-playground')}
|
||||
onTestAI={aiTest.executeTest}
|
||||
result={aiTest.result}
|
||||
isPending={aiTest.isPending}
|
||||
isDisabled={aiTest.isTestDisabled}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const AIIssueFormButton = ({ projectId }: { projectId: number }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { formId, isPending, isDirty } = useAIIssueFormStore();
|
||||
const {
|
||||
isPending: isDeletePending,
|
||||
openDeleteTemplateConfirm,
|
||||
templateId,
|
||||
} = useAIIssueDelete(projectId);
|
||||
const perms = usePermissions(projectId);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="!text-tint-red"
|
||||
onClick={openDeleteTemplateConfirm}
|
||||
disabled={!templateId || !perms.includes('project_genai_update')}
|
||||
loading={isDeletePending}
|
||||
>
|
||||
{t('v2.button.delete')}
|
||||
</Button>
|
||||
<Button
|
||||
form={formId}
|
||||
type="submit"
|
||||
loading={isPending}
|
||||
disabled={!isDirty || !perms.includes('project_genai_update')}
|
||||
>
|
||||
{t('v2.button.save')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES 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, Icon } from '@ufb/react';
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
GRADIENT_CSS,
|
||||
SettingAlert,
|
||||
useAllChannels,
|
||||
useOAIQuery,
|
||||
} from '@/shared';
|
||||
|
||||
export const AiIssueSetting = ({
|
||||
onClick,
|
||||
projectId,
|
||||
}: {
|
||||
onClick: (templateId?: number) => void;
|
||||
projectId: number;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data } = useOAIQuery({
|
||||
path: '/api/admin/projects/{projectId}/ai/issueTemplates',
|
||||
variables: { projectId },
|
||||
});
|
||||
const { data: channelData } = useAllChannels(projectId);
|
||||
const { data: modelData } = useOAIQuery({
|
||||
path: '/api/admin/projects/{projectId}/ai/integrations/models',
|
||||
variables: { projectId },
|
||||
});
|
||||
const [templates, setTemplates] = useState<
|
||||
{
|
||||
id: number;
|
||||
title: string;
|
||||
prompt: string;
|
||||
isError?: boolean;
|
||||
isEnabled?: boolean;
|
||||
}[]
|
||||
>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
setTemplates(
|
||||
data.map((template) => ({
|
||||
id: template.id,
|
||||
title:
|
||||
channelData?.items.find(
|
||||
(channel) => channel.id === template.channelId,
|
||||
)?.name ?? 'Unknown Channel',
|
||||
prompt: template.prompt,
|
||||
isEnabled: template.isEnabled,
|
||||
isError:
|
||||
modelData ?
|
||||
!modelData.models.some((model) => model.id === template.model)
|
||||
: false,
|
||||
})),
|
||||
);
|
||||
}, [data, modelData, channelData]);
|
||||
return (
|
||||
<>
|
||||
<SettingAlert description={t('help-card.ai-issue-recommendation')} />
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{templates.length !== channelData?.items.length && (
|
||||
<TemplateCard
|
||||
type="create"
|
||||
title="Create New"
|
||||
onClick={() => onClick()}
|
||||
/>
|
||||
)}
|
||||
{templates.map((template) => (
|
||||
<TemplateCard
|
||||
key={template.id}
|
||||
type="update"
|
||||
{...template}
|
||||
onClick={() => onClick(template.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const TemplateCard = (props: {
|
||||
title: string;
|
||||
prompt?: string;
|
||||
type: 'create' | 'update';
|
||||
isEnabled?: boolean;
|
||||
isError?: boolean;
|
||||
onClick?: () => void;
|
||||
}) => {
|
||||
const { title, prompt, type, onClick, isEnabled, isError } = props;
|
||||
return (
|
||||
<Card
|
||||
onClick={onClick}
|
||||
className="!rounded-24 min-h-60 cursor-pointer transition-shadow duration-300 hover:shadow-lg"
|
||||
>
|
||||
<CardBody className="flex h-full flex-col justify-between">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
{type === 'create' && (
|
||||
<Icon name="RiAddCircleFill" className="text-neutral-tertiary" />
|
||||
)}
|
||||
{type === 'update' && (
|
||||
<Badge
|
||||
className="text-small-normal w-fit"
|
||||
variant={isEnabled ? 'bold' : 'subtle'}
|
||||
radius="large"
|
||||
>
|
||||
{isEnabled ? 'Enabled' : 'Disabled'}
|
||||
</Badge>
|
||||
)}
|
||||
<h4 className="text-title-h4">
|
||||
{title}{' '}
|
||||
{isError && (
|
||||
<Icon
|
||||
name="RiErrorWarningFill"
|
||||
className="ml-1 text-red-500"
|
||||
size={16}
|
||||
/>
|
||||
)}
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
{prompt && (
|
||||
<div>
|
||||
<p className="text-small-normal">Prompt Preview</p>
|
||||
<div className="bg-neutral-tertiary rounded-12 relative p-3">
|
||||
<p className="text-small-normal line-clamp-2 break-all">
|
||||
{prompt}
|
||||
</p>
|
||||
<div
|
||||
className="rounded-12 absolute inset-0"
|
||||
style={GRADIENT_CSS.fadeOut}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -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 { useState } from 'react';
|
||||
|
||||
import { Icon, ToggleGroup, ToggleGroupItem } from '@ufb/react';
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
cn,
|
||||
} from '@/shared';
|
||||
|
||||
import { AIPlaygroundContextProvider } from '../contexts/ai-playground-context';
|
||||
import type { PlaygroundInputItem } from '../playground-input-item.schema';
|
||||
import PlaygroundInputCard from './playground-input-card.ui';
|
||||
import PlaygroundOutputCard from './playground-output-card.ui';
|
||||
|
||||
interface Props {
|
||||
onTestAI: (inputItems: PlaygroundInputItem[]) => void;
|
||||
result?: string | string[];
|
||||
isPending: boolean;
|
||||
isDisabled: boolean;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const AiPlaygroundTemplate = (props: Props) => {
|
||||
const { onTestAI, result, isPending, isDisabled, description } = props;
|
||||
const [viewType, setViewType] = useState<'vertical' | 'horizontal'>(
|
||||
'vertical',
|
||||
);
|
||||
|
||||
return (
|
||||
<AIPlaygroundContextProvider>
|
||||
<Card
|
||||
size="lg"
|
||||
className="last-of-type: flex h-full flex-[2] flex-col border"
|
||||
>
|
||||
<CardHeader
|
||||
action={
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={viewType}
|
||||
onValueChange={(v) => setViewType(v as 'vertical' | 'horizontal')}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
<ToggleGroupItem value="vertical">
|
||||
<Icon name="RiFlipVerticalLine" />
|
||||
Horizontal View
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="horizontal">
|
||||
<Icon name="RiFlipHorizontalLine" />
|
||||
Vertical View
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
}
|
||||
>
|
||||
<CardTitle>Playground</CardTitle>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardBody
|
||||
className={cn('flex min-h-0 flex-1 gap-4', {
|
||||
'flex-col': viewType === 'vertical',
|
||||
'flex-row': viewType === 'horizontal',
|
||||
})}
|
||||
>
|
||||
<PlaygroundInputCard />
|
||||
<PlaygroundOutputCard
|
||||
result={result}
|
||||
onTestAI={onTestAI}
|
||||
isPending={isPending}
|
||||
isDisabled={isDisabled}
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</AIPlaygroundContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default AiPlaygroundTemplate;
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES 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 { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { Button, toast } from '@ufb/react';
|
||||
|
||||
import {
|
||||
SettingAlert,
|
||||
useOAIMutation,
|
||||
useOAIQuery,
|
||||
usePermissions,
|
||||
useWarnIfUnsavedChanges,
|
||||
} from '@/shared';
|
||||
import { aiSchema, AiSettingForm } from '@/entities/ai';
|
||||
import type { AI } from '@/entities/ai';
|
||||
|
||||
import type { AISettingStore } from '../ai-setting-form.type';
|
||||
|
||||
const useAISettingFormStore = create<AISettingStore>((set) => ({
|
||||
formId: 'ai-setting-form',
|
||||
isPending: false,
|
||||
setIsPending: (isPending) => set({ isPending }),
|
||||
isDirty: false,
|
||||
setIsDirty: (isDirty) => set({ isDirty }),
|
||||
}));
|
||||
|
||||
export const AISettingForm = ({ projectId }: { projectId: number }) => {
|
||||
const { t } = useTranslation();
|
||||
const methods = useForm<AI>({
|
||||
resolver: zodResolver(aiSchema),
|
||||
defaultValues: {
|
||||
provider: 'OPEN_AI',
|
||||
endpointUrl: '',
|
||||
systemPrompt: '',
|
||||
notificationThreshold: null,
|
||||
tokenThreshold: null,
|
||||
},
|
||||
});
|
||||
|
||||
const { formId, setIsPending, setIsDirty } = useAISettingFormStore();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data } = useOAIQuery({
|
||||
path: '/api/admin/projects/{projectId}/ai/integrations',
|
||||
variables: { projectId },
|
||||
queryOptions: { retry: 0 },
|
||||
});
|
||||
|
||||
const { mutateAsync: validateApiKey, isPending: isValidatingApiKey } =
|
||||
useOAIMutation({
|
||||
method: 'post',
|
||||
path: '/api/admin/projects/{projectId}/ai/validate',
|
||||
pathParams: { projectId },
|
||||
});
|
||||
|
||||
const { mutateAsync: update, isPending } = useOAIMutation({
|
||||
method: 'put',
|
||||
path: '/api/admin/projects/{projectId}/ai/integrations',
|
||||
pathParams: { projectId },
|
||||
queryOptions: {
|
||||
async onSuccess() {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: [
|
||||
'/api/admin/projects/{projectId}/ai/integrations',
|
||||
{ projectId },
|
||||
],
|
||||
});
|
||||
toast.success(t('v2.toast.success'));
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setIsPending(isPending || isValidatingApiKey);
|
||||
}, [isPending, isValidatingApiKey]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsDirty(methods.formState.isDirty);
|
||||
}, [methods.formState.isDirty]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) methods.reset(data);
|
||||
}, [data]);
|
||||
|
||||
const onSubmit = async (values: AI) => {
|
||||
const data = await validateApiKey(values);
|
||||
if (!data?.valid) {
|
||||
toast.error(data?.error);
|
||||
return;
|
||||
}
|
||||
await update(values);
|
||||
};
|
||||
|
||||
useWarnIfUnsavedChanges(methods.formState.isDirty);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingAlert description={t('help-card.ai-setting')} />
|
||||
<FormProvider {...methods}>
|
||||
<form id={formId} onSubmit={methods.handleSubmit(onSubmit)}>
|
||||
<AiSettingForm />
|
||||
</form>
|
||||
</FormProvider>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const AISettingFormButton = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { formId, isPending, isDirty } = useAISettingFormStore();
|
||||
const perms = usePermissions();
|
||||
return (
|
||||
<Button
|
||||
form={formId}
|
||||
type="submit"
|
||||
loading={isPending}
|
||||
disabled={!isDirty || !perms.includes('project_genai_update')}
|
||||
>
|
||||
{t('v2.button.save')}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,539 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES 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 { zodResolver } from '@hookform/resolvers/zod';
|
||||
import dayjs from 'dayjs';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import {
|
||||
Label,
|
||||
PolarGrid,
|
||||
PolarRadiusAxis,
|
||||
RadialBar,
|
||||
RadialBarChart,
|
||||
Tooltip,
|
||||
} from 'recharts';
|
||||
import { z } from 'zod';
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { Button, FormField, Switch, toast } from '@ufb/react';
|
||||
|
||||
import type { DateRangeType } from '@/shared';
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
ChartCard,
|
||||
DateRangePicker,
|
||||
Legend,
|
||||
LineChart,
|
||||
SelectInput,
|
||||
SettingAlert,
|
||||
useOAIMutation,
|
||||
useOAIQuery,
|
||||
useWarnIfUnsavedChanges,
|
||||
} from '@/shared';
|
||||
import { FormInput } from '@/shared/ui/form-inputs';
|
||||
|
||||
import type { AISettingStore } from '../ai-setting-form.type';
|
||||
|
||||
const useAIUsageFormStore = create<AISettingStore>((set) => ({
|
||||
formId: 'ai-usage-form',
|
||||
isPending: false,
|
||||
setIsPending: (isPending) => set({ isPending }),
|
||||
isDirty: false,
|
||||
setIsDirty: (isDirty) => set({ isDirty }),
|
||||
}));
|
||||
const usageSchema = z.object({
|
||||
tokenThreshold: z.number().min(1).nullable(),
|
||||
percentage: z.string().optional(),
|
||||
});
|
||||
type UsageForm = z.infer<typeof usageSchema>;
|
||||
|
||||
export const AIUsageForm = ({ projectId }: { projectId: number }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const methods = useForm<UsageForm>({ resolver: zodResolver(usageSchema) });
|
||||
const { formState, setValue, watch, handleSubmit, reset, control } = methods;
|
||||
|
||||
const { formId, setIsPending, setIsDirty } = useAIUsageFormStore();
|
||||
|
||||
const { mutate, isPending } = useOAIMutation({
|
||||
method: 'put',
|
||||
path: '/api/admin/projects/{projectId}/ai/integrations',
|
||||
pathParams: { projectId },
|
||||
queryOptions: {
|
||||
async onSuccess() {
|
||||
toast.success(t('v2.toast.success'));
|
||||
await refetch();
|
||||
setIsDirty(false);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { data: integrationData, refetch } = useOAIQuery({
|
||||
path: '/api/admin/projects/{projectId}/ai/integrations',
|
||||
variables: { projectId },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setIsPending(isPending);
|
||||
}, [isPending]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsDirty(formState.isDirty);
|
||||
}, [formState.isDirty]);
|
||||
useWarnIfUnsavedChanges(formState.isDirty);
|
||||
|
||||
useEffect(() => {
|
||||
if (integrationData) {
|
||||
reset({
|
||||
tokenThreshold: integrationData.tokenThreshold ?? null,
|
||||
percentage:
|
||||
integrationData.notificationThreshold ?
|
||||
(
|
||||
(integrationData.notificationThreshold /
|
||||
(integrationData.tokenThreshold ?? 1)) *
|
||||
100
|
||||
).toFixed(0)
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
}, [integrationData]);
|
||||
|
||||
const onSubmit = (data: UsageForm) => {
|
||||
if (!integrationData) return;
|
||||
const { percentage, tokenThreshold } = data;
|
||||
|
||||
mutate({
|
||||
...integrationData,
|
||||
tokenThreshold: tokenThreshold ? +tokenThreshold : null,
|
||||
notificationThreshold:
|
||||
tokenThreshold && percentage ?
|
||||
tokenThreshold * (parseInt(percentage, 10) / 100)
|
||||
: null,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingAlert description={t('help-card.ai-usage')} />
|
||||
<FormProvider {...methods}>
|
||||
<form id={formId} onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="flex flex-row gap-4">
|
||||
<div className="flex-[2]">
|
||||
<AIChartCard projectId={projectId} />
|
||||
</div>
|
||||
<div className="flex-[1]">
|
||||
<AIChartUsageCard projectId={projectId} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<Card className="!rounded-16 flex-[1]" size="lg">
|
||||
<CardHeader
|
||||
className="flex-1"
|
||||
action={
|
||||
<FormField
|
||||
control={control}
|
||||
name="tokenThreshold"
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
checked={field.value !== null}
|
||||
onCheckedChange={(checked) => {
|
||||
field.onChange(checked ? 0 : null);
|
||||
if (!checked) setValue('percentage', undefined);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<CardTitle>Token Threshold</CardTitle>
|
||||
<CardDescription className="text-base-normal">
|
||||
{t('v2.description.token-threshold')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<FormField
|
||||
control={control}
|
||||
name="tokenThreshold"
|
||||
render={({ field }) => (
|
||||
<FormInput
|
||||
{...field}
|
||||
type="number"
|
||||
disabled={watch('tokenThreshold') === null}
|
||||
onChange={(e) => field.onChange(e.target.valueAsNumber)}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
<Card className="!rounded-16 flex-[1]" size="lg">
|
||||
<CardHeader
|
||||
className="flex-1"
|
||||
action={
|
||||
<FormField
|
||||
control={control}
|
||||
name="percentage"
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
disabled={watch('tokenThreshold') === null}
|
||||
checked={!!field.value}
|
||||
onCheckedChange={(checked) =>
|
||||
field.onChange(checked ? '50' : undefined)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<CardTitle>Usage Notification</CardTitle>
|
||||
<CardDescription className="text-base-normal">
|
||||
{t('v2.description.pre-limit-notification')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<FormField
|
||||
control={control}
|
||||
name="percentage"
|
||||
render={({ field }) => (
|
||||
<SelectInput
|
||||
disabled={!field.value}
|
||||
value={field.value}
|
||||
onChange={(value) => {
|
||||
if (!value) return;
|
||||
field.onChange(value);
|
||||
}}
|
||||
options={[
|
||||
{
|
||||
value: '50',
|
||||
label: t('v2.text.token-threshold-percentage', {
|
||||
percentage: 50,
|
||||
}),
|
||||
},
|
||||
{
|
||||
value: '60',
|
||||
label: t('v2.text.token-threshold-percentage', {
|
||||
percentage: 60,
|
||||
}),
|
||||
},
|
||||
{
|
||||
value: '70',
|
||||
label: t('v2.text.token-threshold-percentage', {
|
||||
percentage: 70,
|
||||
}),
|
||||
},
|
||||
{
|
||||
value: '80',
|
||||
label: t('v2.text.token-threshold-percentage', {
|
||||
percentage: 80,
|
||||
}),
|
||||
},
|
||||
{
|
||||
value: '90',
|
||||
label: t('v2.text.token-threshold-percentage', {
|
||||
percentage: 90,
|
||||
}),
|
||||
},
|
||||
{
|
||||
value: '95',
|
||||
label: t('v2.text.token-threshold-percentage', {
|
||||
percentage: 95,
|
||||
}),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const AIUsageFormButton = () => {
|
||||
const { t } = useTranslation();
|
||||
const { formId, isPending, isDirty } = useAIUsageFormStore();
|
||||
|
||||
return (
|
||||
<Button form={formId} type="submit" disabled={!isDirty} loading={isPending}>
|
||||
{t('v2.button.save')}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const dataKeys = [
|
||||
{ name: 'Total', color: '#4A90E2' },
|
||||
{ name: 'AI Field', color: '#50E3C2' },
|
||||
{ name: 'AI Issue', color: '#F5A623' },
|
||||
];
|
||||
|
||||
const AIChartCard = ({ projectId }: { projectId: number }) => {
|
||||
const [dateRange, setDateRange] = useState<DateRangeType>({
|
||||
startDate: dayjs().startOf('month').toDate(),
|
||||
endDate: dayjs().toDate(),
|
||||
});
|
||||
|
||||
const { data: dailyData } = useOAIQuery({
|
||||
path: '/api/admin/projects/{projectId}/ai/usages',
|
||||
variables: {
|
||||
projectId,
|
||||
from: dateRange?.startDate?.toISOString() ?? '',
|
||||
to: dateRange?.endDate?.toISOString() ?? '',
|
||||
},
|
||||
});
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
const result: Record<
|
||||
string,
|
||||
{
|
||||
date: string;
|
||||
Total: number;
|
||||
'AI Field': number;
|
||||
'AI Issue': number;
|
||||
}
|
||||
> = {};
|
||||
const endDate = dayjs(dateRange?.endDate).endOf('day');
|
||||
let currentDate = dayjs(dateRange?.startDate).startOf('day');
|
||||
while (currentDate.isBefore(endDate)) {
|
||||
const dateKey = currentDate.format('YYYY-MM-DD');
|
||||
result[dateKey] ??= {
|
||||
date: currentDate.format('MM/DD'),
|
||||
Total: 0,
|
||||
'AI Field': 0,
|
||||
'AI Issue': 0,
|
||||
};
|
||||
currentDate = currentDate.add(1, 'day');
|
||||
}
|
||||
|
||||
dailyData?.forEach((entry) => {
|
||||
const dateKey = `${entry.year}-${String(entry.month).padStart(2, '0')}-${String(entry.day).padStart(2, '0')}`;
|
||||
if (result[dateKey]) {
|
||||
result[dateKey].Total += entry.usedTokens;
|
||||
if (entry.category === 'AI_FIELD') {
|
||||
result[dateKey]['AI Field'] += entry.usedTokens;
|
||||
} else {
|
||||
result[dateKey]['AI Issue'] += entry.usedTokens;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return Object.values(result);
|
||||
}, [dailyData, dateRange]);
|
||||
|
||||
return (
|
||||
<ChartCard
|
||||
title="Token Usage"
|
||||
extra={
|
||||
<div className="flex gap-3">
|
||||
<Legend dataKeys={dataKeys} />
|
||||
<DateRangePicker
|
||||
onChange={setDateRange}
|
||||
value={dateRange}
|
||||
maxDate={new Date()}
|
||||
numberOfMonths={1}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LineChart height={334} dataKeys={dataKeys} data={chartData} />
|
||||
</ChartCard>
|
||||
);
|
||||
};
|
||||
const AIChartUsageCard = ({ projectId }: { projectId: number }) => {
|
||||
const { t } = useTranslation();
|
||||
const { data: integrationData } = useOAIQuery({
|
||||
path: '/api/admin/projects/{projectId}/ai/integrations',
|
||||
variables: { projectId },
|
||||
});
|
||||
const { data: monthlyData } = useOAIQuery({
|
||||
path: '/api/admin/projects/{projectId}/ai/usages',
|
||||
variables: {
|
||||
projectId,
|
||||
from: dayjs().startOf('month').toISOString(),
|
||||
to: dayjs().endOf('month').toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
const usedTokens = useMemo(
|
||||
() => monthlyData?.reduce((acc, usage) => acc + usage.usedTokens, 0) ?? 0,
|
||||
[monthlyData],
|
||||
);
|
||||
|
||||
const remainingTokens = useMemo(
|
||||
() => Math.max((integrationData?.tokenThreshold ?? 0) - usedTokens, 0),
|
||||
[integrationData, usedTokens],
|
||||
);
|
||||
|
||||
const usedPercentage = useMemo(
|
||||
() =>
|
||||
integrationData?.tokenThreshold ?
|
||||
(usedTokens / integrationData.tokenThreshold) * 100
|
||||
: 0,
|
||||
[usedTokens, integrationData],
|
||||
);
|
||||
|
||||
const remainingPercentage = useMemo(
|
||||
() =>
|
||||
integrationData?.tokenThreshold ?
|
||||
(remainingTokens / integrationData.tokenThreshold) * 100
|
||||
: 0,
|
||||
[integrationData, remainingTokens],
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className="!rounded-16 h-full" size="lg">
|
||||
<CardHeader>
|
||||
<CardTitle>Monthly Remaining Token</CardTitle>
|
||||
</CardHeader>
|
||||
<CardBody className="flex flex-col items-center gap-8">
|
||||
<RadialBarChart
|
||||
data={[
|
||||
{
|
||||
remainingTokens,
|
||||
fill: '#38BDF8',
|
||||
name: 'Remaining Tokens',
|
||||
},
|
||||
]}
|
||||
startAngle={90}
|
||||
endAngle={Math.max(90 + 360 * (remainingPercentage / 100), 90)}
|
||||
innerRadius={80}
|
||||
outerRadius={110}
|
||||
width={180}
|
||||
height={180}
|
||||
>
|
||||
<Tooltip
|
||||
formatter={(value: unknown) => {
|
||||
if (typeof value === 'number') {
|
||||
return value.toLocaleString();
|
||||
}
|
||||
return String(value);
|
||||
}}
|
||||
cursor={{ fill: 'var(--bg-neutral-tertiary)' }}
|
||||
content={({ payload }) => {
|
||||
return (
|
||||
<div className="bg-neutral-primary border-neutral-tertiary max-w-[240px] rounded border px-4 py-3 shadow-lg">
|
||||
{payload.map((item, i) => {
|
||||
const { value, payload: itemPayload } = item as {
|
||||
value: unknown;
|
||||
payload: unknown;
|
||||
};
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="text-neutral-secondary text-small-normal flex items-center justify-between gap-4"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: (itemPayload as { fill: string })
|
||||
.fill,
|
||||
}}
|
||||
className="h-2 w-2 flex-shrink-0 rounded-full"
|
||||
/>
|
||||
<p className="text-small-normal break-all">
|
||||
{(itemPayload as { name: string | undefined }).name}
|
||||
</p>
|
||||
</div>
|
||||
<p>
|
||||
{typeof value === 'number' ?
|
||||
value.toLocaleString()
|
||||
: String(value)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<PolarGrid
|
||||
gridType="circle"
|
||||
radialLines={false}
|
||||
stroke="none"
|
||||
className="first:fill-[var(--bg-neutral-tertiary)] last:fill-[var(--bg-neutral-primary)]"
|
||||
polarRadius={[86, 74]}
|
||||
/>
|
||||
<RadialBar dataKey="remainingTokens" background />
|
||||
<PolarRadiusAxis tick={false} tickLine={false} axisLine={false}>
|
||||
<Label
|
||||
content={({ viewBox }) => {
|
||||
if (viewBox && 'cx' in viewBox && 'cy' in viewBox) {
|
||||
return (
|
||||
<text
|
||||
x={viewBox.cx}
|
||||
y={viewBox.cy}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
>
|
||||
<tspan
|
||||
x={viewBox.cx}
|
||||
y={viewBox.cy - 12}
|
||||
className="text-title-h4 fill-neutral-primary"
|
||||
>
|
||||
{integrationData?.tokenThreshold ?
|
||||
`${remainingPercentage.toFixed(1)}%`
|
||||
: '-'}
|
||||
</tspan>
|
||||
<tspan
|
||||
x={viewBox.cx}
|
||||
y={viewBox.cy + 12}
|
||||
className="fill-neutral-secondary text-small-normal"
|
||||
>
|
||||
Remaining Tokens
|
||||
</tspan>
|
||||
</text>
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</PolarRadiusAxis>
|
||||
</RadialBarChart>
|
||||
{integrationData?.tokenThreshold ?
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="flex justify-between">
|
||||
<div>Used Tokens</div>
|
||||
<div>
|
||||
{usedTokens.toLocaleString()} ({usedPercentage.toFixed(1)}%)
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<div>Remaining Tokens</div>
|
||||
<div>
|
||||
{remainingTokens.toLocaleString()} (
|
||||
{remainingPercentage.toFixed(1)}%)
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<div>Tokens reset in</div>
|
||||
<div>1st of every month</div>
|
||||
</div>
|
||||
</div>
|
||||
: <p className="text-center">
|
||||
{t('v2.description.remaining-token-amount')}
|
||||
</p>
|
||||
}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES 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 from 'react';
|
||||
|
||||
interface EmptyStateProps {
|
||||
image: React.ReactNode;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export const EmptyState = ({ image, message }: EmptyStateProps) => (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4">
|
||||
{image}
|
||||
<p className="text-small-normal text-neutral-tertiary">{message}</p>
|
||||
</div>
|
||||
);
|
||||
@@ -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 * from './ai-setting.ui';
|
||||
export * from './ai-usage.ui';
|
||||
export * from './ai-field-template-setting.ui';
|
||||
export * from './ai-field-template-form.ui';
|
||||
export * from './ai-issue-setting.ui';
|
||||
export * from './ai-issue-form.ui';
|
||||
@@ -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 React from 'react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
import { Button, Icon } from '@ufb/react';
|
||||
|
||||
import { Card, CardBody, CardHeader, CardTitle } from '@/shared';
|
||||
|
||||
import { NodataImage } from '@/assets';
|
||||
import { useAIPlayground } from '../contexts/ai-playground-context';
|
||||
import { EmptyState } from './empty-state';
|
||||
import PlaygroundInputData from './playground-input-data';
|
||||
|
||||
const PlaygroundInputCard = () => {
|
||||
const { t } = useTranslation();
|
||||
const { inputItems, addNewEditingItem } = useAIPlayground();
|
||||
|
||||
return (
|
||||
<Card className="flex min-h-0 flex-1 flex-col">
|
||||
<CardHeader
|
||||
action={
|
||||
<Button variant="outline" onClick={addNewEditingItem}>
|
||||
<Icon name="RiAddLine" />
|
||||
{t('v2.button.name.add', { name: 'Data' })}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<CardTitle>Test Data (Input)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardBody className="flex min-h-0 flex-1 flex-col gap-4 overflow-auto">
|
||||
{inputItems.length === 0 ?
|
||||
<EmptyState
|
||||
image={<NodataImage width={120} height={120} />}
|
||||
message={t('v2.text.no-data.default')}
|
||||
/>
|
||||
: inputItems.map(({ id }) => <PlaygroundInputData key={id} id={id} />)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
export default PlaygroundInputCard;
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES 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 { Button, Icon } from '@ufb/react';
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
TextInput,
|
||||
} from '@/shared';
|
||||
|
||||
import { useAIPlayground } from '../contexts/ai-playground-context';
|
||||
import type { PlaygroundInputItem } from '../playground-input-item.schema';
|
||||
import { playgroundInputItemSchema } from '../playground-input-item.schema';
|
||||
|
||||
interface AIPlaygroundInputDataItemFormProps {
|
||||
onSubmit: (input: PlaygroundInputItem) => void;
|
||||
onClose: () => void;
|
||||
initialValues?: PlaygroundInputItem;
|
||||
id: string;
|
||||
}
|
||||
|
||||
const PlaygroundInputDataItem = ({
|
||||
onSubmit,
|
||||
onClose,
|
||||
initialValues,
|
||||
id,
|
||||
}: AIPlaygroundInputDataItemFormProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { inputItems } = useAIPlayground();
|
||||
const { register, handleSubmit, reset, formState, setError } =
|
||||
useForm<PlaygroundInputItem>({
|
||||
defaultValues: initialValues ?? { name: '', description: '', value: '' },
|
||||
resolver: zodResolver(playgroundInputItemSchema),
|
||||
});
|
||||
|
||||
const handleFormSubmit = handleSubmit((data) => {
|
||||
if (inputItems.some((item) => item.name === data.name && item.id !== id)) {
|
||||
setError('name', {
|
||||
type: 'manual',
|
||||
message: 'A data item with this name already exists.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
onSubmit(data);
|
||||
});
|
||||
|
||||
const handleCancel = () => {
|
||||
reset();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Card size="sm">
|
||||
<CardBody>
|
||||
<form className="flex flex-col gap-1" onSubmit={handleFormSubmit}>
|
||||
<TextInput
|
||||
label="Field Name"
|
||||
{...register('name')}
|
||||
required
|
||||
placeholder={t('v2.placeholder.text')}
|
||||
error={formState.errors.name?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label="Field Description"
|
||||
{...register('description')}
|
||||
placeholder={t('v2.placeholder.text')}
|
||||
error={formState.errors.description?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label="Field Value"
|
||||
{...register('value')}
|
||||
required
|
||||
placeholder={t('v2.placeholder.text')}
|
||||
error={formState.errors.value?.message}
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button onClick={handleCancel} variant="outline">
|
||||
{t('v2.button.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={!formState.isDirty}>
|
||||
{t('v2.button.confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
interface PlaygroundInputDataProps {
|
||||
id: string;
|
||||
}
|
||||
|
||||
const PlaygroundInputData = ({ id }: PlaygroundInputDataProps) => {
|
||||
const { inputItems, updateInputItem, deleteInputItem } = useAIPlayground();
|
||||
const originalData = inputItems.find((item) => item.id === id);
|
||||
|
||||
const setIsEditing = (isEditing: boolean) => {
|
||||
if (!originalData) return;
|
||||
updateInputItem(id, { ...originalData, isEditing });
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!originalData) return;
|
||||
if (originalData.name === '') {
|
||||
deleteInputItem(id);
|
||||
return;
|
||||
}
|
||||
updateInputItem(id, { ...originalData, isEditing: false });
|
||||
};
|
||||
|
||||
const handleSubmit = (newData: PlaygroundInputItem) => {
|
||||
updateInputItem(id, { ...newData, isEditing: false });
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
deleteInputItem(id);
|
||||
};
|
||||
|
||||
if (!originalData) return null;
|
||||
|
||||
if (originalData.isEditing) {
|
||||
return (
|
||||
<PlaygroundInputDataItem
|
||||
initialValues={originalData}
|
||||
onClose={handleClose}
|
||||
onSubmit={handleSubmit}
|
||||
id={id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card size="sm">
|
||||
<CardHeader
|
||||
action={
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => setIsEditing(true)}>
|
||||
<Icon name="RiEditBoxLine" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDelete}
|
||||
variant="outline"
|
||||
className="!text-tint-red"
|
||||
>
|
||||
<Icon name="RiDeleteBinLine" />
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<CardTitle>{originalData.name}</CardTitle>
|
||||
<CardDescription>{originalData.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div className="bg-neutral-tertiary rounded-8 max-h-16 overflow-auto p-3">
|
||||
<p className="text-small-normal text-neutral-secondary">
|
||||
{originalData.value}
|
||||
</p>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlaygroundInputData;
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES 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 from 'react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import ContentLoader from 'react-content-loader';
|
||||
|
||||
import { Badge, Button, Icon } from '@ufb/react';
|
||||
|
||||
import { Card, CardBody, CardHeader, CardTitle, GRADIENT_CSS } from '@/shared';
|
||||
|
||||
import { useAIPlayground } from '../contexts/ai-playground-context';
|
||||
import type { PlaygroundInputItem } from '../playground-input-item.schema';
|
||||
|
||||
// Loading Component
|
||||
type LoadingBarsProps = React.ComponentProps<typeof ContentLoader>;
|
||||
export const LoadingBars = (props: LoadingBarsProps) => (
|
||||
<ContentLoader
|
||||
speed={3.6}
|
||||
width={534}
|
||||
height={76}
|
||||
viewBox="0 0 534 76"
|
||||
backgroundColor="#2a8dcb"
|
||||
foregroundColor="#6fecd7"
|
||||
{...props}
|
||||
>
|
||||
<path d="M 0 10 C 0 4.477 4.477 0 10 0 h 514 c 5.523 0 10 4.477 10 10 s -4.477 10 -10 10 H 10 C 4.477 20 0 15.523 0 10 z M 0 38 c 0 -5.523 4.477 -10 10 -10 h 376 c 5.523 0 10 4.477 10 10 s -4.477 10 -10 10 H 10 C 4.477 48 0 43.523 0 38 z M 0 66 c 0 -5.523 4.477 -10 10 -10 h 452 c 5.523 0 10 4.477 10 10 s -4.477 10 -10 10 H 10 C 4.477 76 0 71.523 0 66 z" />{' '}
|
||||
</ContentLoader>
|
||||
);
|
||||
|
||||
// Result Display Component
|
||||
export const ResultBadges = ({ results }: { results: string[] }) => (
|
||||
<div className="flex gap-2">
|
||||
{results.map((item, index) => (
|
||||
<Badge key={index} style={GRADIENT_CSS.primary}>
|
||||
{item}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Text Result Display Component
|
||||
export const TextResult = ({ result }: { result: string }) => (
|
||||
<div className="bg-neutral-tertiary rounded-8 overflow-auto p-3">
|
||||
<p className="text-small-normal text-neutral-secondary">{result}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
interface AIResultSectionProps {
|
||||
result?: string | string[];
|
||||
onTestAI: (inputItems: PlaygroundInputItem[]) => void;
|
||||
isPending: boolean;
|
||||
isDisabled: boolean;
|
||||
}
|
||||
|
||||
const PlaygroundOutputCard = ({
|
||||
result,
|
||||
onTestAI,
|
||||
isPending,
|
||||
isDisabled,
|
||||
}: AIResultSectionProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { inputItems } = useAIPlayground();
|
||||
|
||||
return (
|
||||
<Card className="flex-1">
|
||||
<CardHeader
|
||||
action={
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onTestAI(inputItems)}
|
||||
loading={isPending}
|
||||
disabled={isDisabled || inputItems.length === 0}
|
||||
>
|
||||
<Icon name="RiSparklingFill" />
|
||||
{t('v2.button.process-ai-test')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<CardTitle>AI Result (Output)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
{isPending ?
|
||||
<LoadingBars />
|
||||
: result ?
|
||||
Array.isArray(result) ?
|
||||
<ResultBadges results={result} />
|
||||
: typeof result === 'string' ?
|
||||
<TextResult result={result} />
|
||||
: null
|
||||
: null}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlaygroundOutputCard;
|
||||
Reference in new issue
Block a user