Initial deployment setup
Deploy staging / deploy (push) Failing after 6s

This commit is contained in:
root committed 2026-08-31 16:45:24 +09:00
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';
@@ -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(),
});
@@ -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';
@@ -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;
@@ -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;