diff options
Diffstat (limited to 'lib')
245 files changed, 46874 insertions, 0 deletions
diff --git a/lib/admin-users/repository.ts b/lib/admin-users/repository.ts new file mode 100644 index 00000000..aff2da28 --- /dev/null +++ b/lib/admin-users/repository.ts @@ -0,0 +1,171 @@ +import db from "@/db/db"; +import { users, userRoles,userView,roles, type User, type UserRole, type UserView, Role } from "@/db/schema/users"; +import { companies, type Company } from "@/db/schema/companies"; +import { + eq, + inArray, + asc, + desc, + and, + count, + gt, + sql, + SQL, +} from "drizzle-orm"; +import { PgTransaction } from "drizzle-orm/pg-core"; +import { Vendor, vendors } from "@/db/schema/vendors"; + +// ============================================================ +// 타입 +// ============================================================ + +export type NewUser = typeof users.$inferInsert; // User insert 시 필요한 타입 +export type NewUserRole = typeof userRoles.$inferInsert; // UserRole insert 시 필요한 타입 +export type NewCompany = typeof companies.$inferInsert; // Company insert 시 필요한 타입 + + + +export async function selectUsersWithCompanyAndRoles( + tx: PgTransaction<any, any, any>, + params: { + where?: any + orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[] + offset?: number + limit?: number + } +) { + const { where, orderBy, offset = 0, limit = 10 } = params + + // 1) 쿼리 빌더 생성 + const queryBuilder = tx + .select() + .from(userView) + .where(where) + .orderBy(...(orderBy ?? [])) + .offset(offset) + .limit(limit) + + const rows = await queryBuilder + return rows +} + + +/** 총 개수 count */ +export async function countUsers( + tx: PgTransaction<any, any, any>, + where?: any +) { + const res = await tx.select({ count: count() }).from(userView).where(where); + return res[0]?.count ?? 0; +} + +export async function groupByCompany( + tx: PgTransaction<any, any, any>, +) { + return tx + .select({ + companyId: users.companyId, + count: count(), + }) + .from(users) + .groupBy(users.companyId) + .having(gt(count(), 0)); +} + +export async function groupByRole(tx: PgTransaction<any, any, any>) { + return tx + .select({ + roleId: userRoles.roleId, + count: sql<number>`COUNT(*)`.as("count"), + }) + .from(users) + .leftJoin(userRoles, eq(userRoles.userId, users.id)) + .leftJoin(roles, eq(roles.id, userRoles.roleId)) + .groupBy(userRoles.roleId, roles.id, roles.name) + .having(gt(sql<number>`COUNT(*)` /* 또는 count()와 동일 */, 0)); +} + +export async function insertUser( + tx: PgTransaction<any, any, any>, + data: NewUser +) { + return tx.insert(users).values(data).returning(); +} + +export async function insertUserRole( + tx: PgTransaction<any, any, any>, + data: NewUserRole +) { + return tx.insert(userRoles).values(data).returning(); +} + +export async function updateUser( + tx: PgTransaction<any, any, any>, + userId: number, + data: Partial<User> +) { + return tx + .update(users) + .set(data) + .where(eq(users.id, userId)) + .returning(); +} + +/** 복수 업데이트 */ +export async function updateUsers( + tx: PgTransaction<any, any, any>, +ids: number[], +data: Partial<User> +) { +return tx + .update(users) + .set(data) + .where(inArray(users.id, ids)) + .returning({ companyId: users.companyId }); +} + +export async function deleteRolesByUserId( + tx: PgTransaction<any, any, any>, + userId: number +) { + return tx.delete(userRoles).where(eq(userRoles.userId, userId)); +} + + +export async function deleteRolesByUserIds( + tx: PgTransaction<any, any, any>, + ids: number[] +) { + return tx.delete(userRoles).where(inArray(userRoles.userId, ids)); +} + +export async function deleteUserById( + tx: PgTransaction<any, any, any>, + userId: number +) { + return tx.delete(users).where(eq(users.id, userId)); +} + + +export async function deleteUsersByIds( + tx: PgTransaction<any, any, any>, + ids: number[] +) { + return tx.delete(users).where(inArray(users.id, ids)); +} + +export async function findAllCompanies(): Promise<Vendor[]> { + return db.select().from(vendors).orderBy(asc(vendors.vendorName)); +} + +export async function findAllRoles(): Promise<Role[]> { + return db.select().from(roles).where(eq(roles.domain ,'partners')).orderBy(asc(roles.name)); +} + +export const getUserById = async (id: number): Promise<UserView | null> => { + const userFouned = await db.select().from(userView).where(eq(userView.user_id, id)).execute(); + if (userFouned.length === 0) return null; + + const user = userFouned[0]; + return user +}; diff --git a/lib/admin-users/service.ts b/lib/admin-users/service.ts new file mode 100644 index 00000000..5d738d38 --- /dev/null +++ b/lib/admin-users/service.ts @@ -0,0 +1,531 @@ +"use server"; + +import { revalidateTag, unstable_noStore } from "next/cache"; +import db from "@/db/db"; +import logger from '@/lib/logger'; + +import { Role, roles, users, userView, type User, type UserView } from "@/db/schema/users"; // User 테이블 +import { type Company } from "@/db/schema/companies"; // User 테이블 +import { asc, desc, ilike, inArray, and, gte, lte, not, or, eq } from "drizzle-orm"; + +// 레포지토리 함수들 (예시) - 아래처럼 작성했다고 가정 +import { + selectUsersWithCompanyAndRoles, + countUsers, + insertUser, + insertUserRole, + updateUser, deleteRolesByUserId, deleteRolesByUserIds, + deleteUserById, + deleteUsersByIds, + groupByCompany, + groupByRole, + findAllCompanies, getUserById, updateUsers, + findAllRoles +} from "./repository"; + +import { filterColumns } from "@/lib/filter-columns"; +import { unstable_cache } from "@/lib/unstable-cache"; +import { getErrorMessage } from "@/lib/handle-error"; + +// types +import type { CreateUserSchema, UpdateUserSchema, GetUsersSchema } from "./validations"; + +import { sendEmail } from "@/lib//mail/sendEmail"; +import { Vendor } from "@/db/schema/vendors"; + +/** + * 복잡한 조건으로 User 목록을 조회 (+ pagination) 하고, + * 총 개수에 따라 pageCount를 계산해서 리턴. + * Next.js의 unstable_cache를 사용해 일정 시간 캐시. + */ + + +export async function getUsers(input: GetUsersSchema) { + return unstable_cache( + async () => { + try { + const offset = (input.page - 1) * input.perPage; + + // (1) advancedWhere + const advancedWhere = filterColumns({ + table: userView, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + // (2) globalWhere + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + ilike(userView.user_name, s), + ilike(userView.user_email, s), + ilike(userView.company_name, s) + ); + } + + // (3) 디폴트 domainWhere = eq(userView.domain, "partners") + // 다만, 사용자가 이미 domain 필터를 줬다면 적용 X + let domainWhere; + const hasDomainFilter = input.filters?.some((f) => f.id === "user_domain"); + if (!hasDomainFilter) { + domainWhere = eq(userView.user_domain, "partners"); + } + + // (4) 최종 where + const finalWhere = and(advancedWhere, globalWhere, domainWhere); + + // (5) 정렬 + const orderBy = + input.sort.length > 0 + ? input.sort.map((item) => + item.desc ? desc(userView[item.id]) : asc(userView[item.id]) + ) + : [desc(users.createdAt)]; + + // ... + const { data, total } = await db.transaction(async (tx) => { + const data = await selectUsersWithCompanyAndRoles(tx, { + where: finalWhere, + orderBy, + offset, + limit: input.perPage, + }); + + const total = await countUsers(tx, finalWhere); + return { data, total }; + }); + + const pageCount = Math.ceil(total / input.perPage); + return { data, pageCount }; + } catch (err) { + return { data: [], pageCount: 0 }; + } + }, + [JSON.stringify(input)], + { + revalidate: 3600, + tags: ["users"], + } + )(); +} + +export async function findUserById(id: number) { + try { + logger.info({ id }, 'Fetching user by ID'); + const user = await getUserById(id); + if (!user) { + logger.warn({ id }, 'User not found'); + } else { + logger.debug({ user }, 'User fetched successfully'); + } + return user; + } catch (error) { + logger.error({ error }, 'Error fetching user by ID'); + throw new Error('Failed to fetch user'); + } +}; + +/** + * User 생성 + * 필요 시 companyId, roles, etc. 함께 처리 + */ +// export async function createUser(input: CreateUserSchema & { language?: string }) { +// unstable_noStore(); // 캐싱 방지(Next.js 서버 액션용) +// try { +// const userLang = input.language || "en"; // 클라이언트가 안 주면 기본 "en" +// // 예시 subject 분기 +// const subject = +// userLang === "ko" +// ? "[eVCP] 어드민 계정이 생성되었습니다." +// : "[eVCP] Admin Account Created"; + +// const loginUrl = +// userLang === "ko" +// ? "http://3.36.56.124:3000/ko/login" +// : "http://3.36.56.124:3000/en/login"; + +// // 실제 sendEmail +// await sendEmail({ +// to: input.email, +// subject, +// template: "admin-created", +// context: { +// name: input.name, +// loginUrl, // 위에서 분기한 URL +// language: userLang, // 템플릿에서 {{t ... lng=language}} 처럼 쓸 수도 +// }, +// }); + +// await db.transaction(async (tx) => { +// // insertUser는 단건 생성 +// const [newUser] = await insertUser(tx, { +// name: input.name, +// email: input.email, +// domain: input.domain, +// companyId: input.companyId ?? null, +// // 기타 필요한 필드 +// }); + +// // 만약 roles를 함께 생성하려면, +// await insertUserRole(tx, { userId: newUser.id, roleId: Number(r) }); +// } +// }); + +// // 캐시 무효화 +// revalidateTag("users"); +// revalidateTag("user-company-counts"); + + + +// return { data: null, error: null }; +// } catch (err) { +// return { data: null, error: getErrorMessage(err) }; +// } +// } + +export async function createAdminUser(input: CreateUserSchema & { language?: string }) { + unstable_noStore(); // Next.js 캐싱 방지 + + try { + // 예) 관리자 메일 알림 로직 + // roles에 'Vendor Admin'을 넣을 거라면, 사실상 input.roles.includes("admin") 체크 대신 + // 아래에서 직접 메일 보내도 됨. 질문 예시대로 유지하겠습니다. + const userLang = input.language || "en"; + const subject = userLang === "ko" + ? "[eVCP] 어드민 계정이 생성되었습니다." + : "[eVCP] Admin Account Created"; + + const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://3.36.56.124:3000' + + const loginUrl = userLang === "ko" + ? `${baseUrl}/ko/partners` + : `${baseUrl}/en/partners`; + + await sendEmail({ + to: input.email, + subject, + template: "admin-created", // 예: nodemailer + handlebars 등 + context: { + name: input.name, + loginUrl, + language: userLang, + }, + }); + + // 트랜잭션 시작 + await db.transaction(async (tx) => { + // 1. 먼저 roles 테이블에서 name = "Vendor Admin" AND domain = input.domain 인 것을 찾는다. + let [vendorAdminRole] = await tx + .select() + .from(roles) + .where( + and( + eq(roles.name, "Vendor Admin"), + eq(roles.domain, input.domain), + eq(roles.companyId, input.companyId as number), + ) + ) + .limit(1); + + // 2. 만약 없다면, 새롭게 생성한다. + if (!vendorAdminRole) { + // companyId나 description 등은 필요에 따라 조정 + const insertedRoles = await tx + .insert(roles) + .values({ + name: "Vendor Admin", + domain: input.domain, + companyId: input.companyId ?? null, + description: "Auto created Vendor Admin role", + }) + .returning(); + vendorAdminRole = insertedRoles[0]; // 방금 insert한 row + } + + // 3. 유저 생성 + const [newUser] = await insertUser(tx, { + name: input.name, + email: input.email, + domain: input.domain, + companyId: input.companyId ?? null, + // 기타 필요한 필드 추가 + }); + + // 4. Vendor Admin role을 user_roles 에 할당 (반복문 없이 단일 insert) + await insertUserRole(tx, { + userId: newUser.id, + roleId: vendorAdminRole.id, // Number()로 캐스팅할 필요 없이 정수로 관리한다고 가정 + }); + }); + + // 캐시 무효화 + revalidateTag("users"); + revalidateTag("user-company-counts"); + + return { data: null, error: null }; + } catch (err) { + return { data: null, error: getErrorMessage(err) }; + } +} + +/** + * 회사별 유저 개수 groupBy + */ +export async function getUserCountGroupByCompany() { + return unstable_cache( + async () => { + try { + // 예: { [companyId: number]: number } + const result = await db.transaction(async (tx) => { + const rows = await groupByCompany(tx); + // groupByCompany(tx): SELECT companyId, COUNT(*) FROM users GROUP BY companyId HAVING COUNT(*) > 0 + // 예: [{ companyId: 1, count: 10 }, { companyId: 2, count: 3 }, ...] + + // reduce해서 {1: 10, 2: 3, ...} 형태로 만들거나 그대로 반환할 수 있음 + const obj: Record<number, number> = {}; + for (const row of rows) { + if (row.companyId !== null) { + obj[row.companyId] = row.count; + } else { + // companyId가 null인 유저 수 + obj[-1] = (obj[-1] ?? 0) + row.count; + } + } + return obj; + }); + return result; + } catch (err) { + return {}; + } + }, + ["user-company-counts"], + { + revalidate: 3600, + } + )(); +} + +/** + * 롤별 유저 개수 groupBy + */ +export async function getUserCountGroupByRole() { + return unstable_cache( + async () => { + try { + const result = await db.transaction(async (tx) => { + const rows = await groupByRole(tx); + + const obj: Record<number, number> = {}; + for (const row of rows) { + if (row.roleId !== null) { + obj[row.roleId] = row.count; + } else { + // roleId가 null인 유저 수 + obj[-1] = (obj[-1] ?? 0) + row.count; + } + } + return obj; + }); + + // 여기서 result를 반환해 줘야 함! + return result; + } catch (err) { + console.error("getUserCountGroupByRole error:", err); + return {}; + } + }, + ["user-role-counts"], + { + revalidate: 3600, + } + )(); +} +/** + * 단건 업데이트 + */ +export async function modifiUser(input: UpdateUserSchema & { id: number } & { language?: string }) { + unstable_noStore(); + + try { + + const oldUser = await getUserById(input.id) + const oldEmail = oldUser?.user_email ?? null; + + const data = await db.transaction(async (tx) => { + // 1) 먼저 User 테이블 업데이트 + const [res] = await updateUser(tx, input.id, { + name: input.name, + companyId: input.companyId, + email: input.email, + }); + + // 2) roles가 함께 왔다면, 기존 roles 삭제 → 새 roles 삽입 + if (input.roles) { + // 기존 roles 삭제 + await deleteRolesByUserId(tx, input.id); + + // 새 roles 삽입 + for (const r of input.roles) { + await insertUserRole(tx, { + userId: input.id, + roleId: Number(r), + }); + } + } + + return res; + }); + + // 3) 캐시 무효화 + revalidateTag("users"); + + // 4) 이메일이 변경되었고, roles 중에 "admin"이 있다면 → 메일 발송 + const isEmailChanged = oldEmail && input.email && oldEmail !== input.email; + const hasAdminRole = input.roles?.includes("admin") ?? false; + + if (isEmailChanged && hasAdminRole && input.email) { + await sendEmail({ + to: input.email, + subject: "[EVCP] Admin Email Changed", + template: "admin-email-changed", + context: { + name: input.name, + oldEmail, + newEmail: input.email, + language: input.language ?? "en", + }, + }); + } + + // 예: companyId 변경 시 회사별 count도 다시 계산해야 하는 경우 + if (data.companyId === input.companyId) { + revalidateTag("user-company-counts"); + } + + + return { data: null, error: null }; + } catch (err) { + return { data: null, error: getErrorMessage(err) }; + } +} + + +/** 복수 업데이트 */ +export async function modifiUsers(input: { + ids: number[]; // 업데이트 대상 유저 ID 배열 + companyId?: User["companyId"]; // 회사 ID (있으면 업데이트) + roles?: UserView["roles"]; // 새 roles 배열 (있으면 업데이트) +}) { + unstable_noStore() // Next.js 서버 액션 캐싱 방지 + + try { + await db.transaction(async (tx) => { + // 1) 회사 정보 업데이트 + if (typeof input.companyId !== "undefined") { + // companyId가 주어졌으면, 해당 사용자들의 companyId 변경 + await updateUsers(tx, input.ids, { companyId: input.companyId }) + } + + // 2) roles 업데이트 + // (있으면 기존 roles 삭제 → 새 roles 삽입) + if (Array.isArray(input.roles)) { + // (a) 기존 roles 전부 삭제 + await deleteRolesByUserIds(tx, input.ids) + + // (b) 새 roles 삽입 + for (const userId of input.ids) { + for (const r of input.roles) { + await insertUserRole(tx, { + userId, + roleId: Number(r), + }) + } + } + } + }) + + // 캐시 무효화 + revalidateTag("users") + + return { data: null, error: null } + } catch (err) { + return { data: null, error: getErrorMessage(err) } + } +} +/** + * 단건 삭제 + */ +export async function removeUser(input: { id: number }) { + unstable_noStore(); + + try { + await db.transaction(async (tx) => { + // 유저 삭제 + await deleteRolesByUserId(tx, input.id); + await deleteUserById(tx, input.id); + // roles, otps 등도 함께 삭제해야 하면 여기서 처리 + }); + + // 캐시 무효화 + revalidateTag("users"); + revalidateTag("user-company-counts"); + + return { data: null, error: null }; + } catch (err) { + return { data: null, error: getErrorMessage(err) }; + } +} + +/** + * 복수 삭제 + */ +export async function removeUsers(input: { ids: number[] }) { + unstable_noStore(); + + try { + await db.transaction(async (tx) => { + // user_roles도 있으면 먼저 삭제해야 할 수 있음 + await deleteRolesByUserIds(tx, input.ids); + await deleteUsersByIds(tx, input.ids); + }); + + revalidateTag("users"); + revalidateTag("user-company-counts"); + + return { data: null, error: null }; + } catch (err) { + return { data: null, error: getErrorMessage(err) }; + } +} + +export async function getAllCompanies(): Promise<Vendor[]> { + try { + return await findAllCompanies(); // Company[] + } catch (err) { + throw new Error("Failed to get companies"); + } +} + +export async function getAllRoles(): Promise<Role[]> { + try { + return await findAllRoles(); + } catch (err) { + throw new Error("Failed to get roles"); + } +} + +/** + * 이미 해당 이메일이 users 테이블에 존재하는지 확인하는 함수 + * @param email 확인할 이메일 + * @returns boolean - 존재하면 true, 없으면 false + */ +export async function checkEmailExists(email: string): Promise<boolean> { + const result = await db + .select({ id: users.id }) // 굳이 모든 컬럼 필요 없으니 id만 + .from(users) + .where(eq(users.email, email)) + .limit(1); + + return result.length > 0; // 1건 이상 있으면 true +} diff --git a/lib/admin-users/table/add-ausers-dialog.tsx b/lib/admin-users/table/add-ausers-dialog.tsx new file mode 100644 index 00000000..dd29c190 --- /dev/null +++ b/lib/admin-users/table/add-ausers-dialog.tsx @@ -0,0 +1,348 @@ +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" + +// react-hook-form + shadcn/ui Form +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Role, userRoles } from "@/db/schema/users" +import { createUserSchema, type CreateUserSchema } from "@/lib/admin-users/validations" +import { createAdminUser, getAllCompanies, getAllRoles } from "@/lib/admin-users/service" +import { type Company } from "@/db/schema/companies" +import { MultiSelect } from "@/components/ui/multi-select" +import { + Popover, + PopoverTrigger, + PopoverContent, +} from "@/components/ui/popover" +import { + Command, + CommandInput, + CommandList, + CommandGroup, + CommandItem, + CommandEmpty, +} from "@/components/ui/command" +import { Check, ChevronsUpDown, Loader } from "lucide-react" +import { cn } from "@/lib/utils" +import { toast } from "sonner" +import { Vendor } from "@/db/schema/vendors" + +const languageOptions = [ + { value: "ko", label: "한국어" }, + { value: "en", label: "English" }, +] + + +export function AddUserDialog() { + const [open, setOpen] = React.useState(false) + const [companies, setCompanies] = React.useState<Vendor[]>([]) // 회사 목록 + const [roles, setRoles] = React.useState<Role[]>([]) + const [isAddPending, startAddTransition] = React.useTransition() + + + + React.useEffect(() => { + // 회사 목록 불러오기 (예시) + getAllCompanies().then((res) => { + setCompanies(res) + }) + + getAllRoles().then((res) => { + setRoles(res) + }) + }, []) + + // react-hook-form 세팅 + const form = useForm<CreateUserSchema & { language?: string }>({ + resolver: zodResolver(createUserSchema), + defaultValues: { + name: "", + email: "", + companyId: null, + language:'en', + // roles는 array<string>, 여기서는 단일 선택 시 [role]로 담음 + roles: [], + domain:'partners' + // domain, etc. 필요하다면 추가 + }, + }) + + + async function onSubmit(data: CreateUserSchema & { language?: string }) { + data.domain = "partners" + + // 만약 단일 Select로 role을 정했다면, data.roles = ["manager"] 이런 식 + startAddTransition(async ()=> { + const result = await createAdminUser(data) + if (result.error) { + toast.error(`에러: ${result.error}`) + return + } + form.reset() + setOpen(false) + toast.success("User added") + }) + + } + + function handleDialogOpenChange(nextOpen: boolean) { + if (!nextOpen) { + form.reset() + } + setOpen(nextOpen) + } + + return ( + <Dialog open={open} onOpenChange={handleDialogOpenChange}> + {/* 모달을 열기 위한 버튼 */} + <DialogTrigger asChild> + <Button variant="default" size="sm"> + Add User + </Button> + </DialogTrigger> + + <DialogContent> + <DialogHeader> + <DialogTitle>Create New User</DialogTitle> + <DialogDescription> + 새 User 정보를 입력하고 <b>Create</b> 버튼을 누르세요. + </DialogDescription> + </DialogHeader> + + {/* shadcn/ui Form을 이용해 react-hook-form과 연결 */} + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)}> + <div className="space-y-4 py-4"> + {/* 사용자 이름 */} + <FormField + control={form.control} + name="name" + render={({ field }) => ( + <FormItem> + <FormLabel>User Name</FormLabel> + <FormControl> + <Input + placeholder="e.g. dujin" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 이메일 */} + <FormField + control={form.control} + name="email" + render={({ field }) => ( + <FormItem> + <FormLabel>Email</FormLabel> + <FormControl> + <Input + placeholder="e.g. user@example.com" + type="email" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 회사 선택 (companyId) */} + <FormField + control={form.control} + name="companyId" + render={({ field }) => { + // 현재 선택된 회사 ID (number) → 문자열 + const valueString = field.value ? String(field.value) : "" + + + // 현재 선택된 회사 + const selectedCompany = companies.find( + (c) => String(c.id) === valueString + ) + + const selectedCompanyLabel = selectedCompany && `${selectedCompany.vendorName} ${selectedCompany.taxId}` + + const [popoverOpen, setPopoverOpen] = React.useState(false) + + + return ( + <FormItem> + <FormLabel>Company</FormLabel> + <FormControl> + <Popover + open={popoverOpen} + onOpenChange={setPopoverOpen} + modal={true} + > + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={popoverOpen} + className="w-full justify-between" + > + {selectedCompany + ? `${selectedCompany.vendorName} ${selectedCompany.taxId}` + : "Select company..."} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + + <PopoverContent className="w-full p-0"> + <Command> + <CommandInput + placeholder="Search company..." + className="h-9" + + /> + <CommandList> + <CommandEmpty>No company found.</CommandEmpty> + <CommandGroup> + {companies.map((comp) => { + // string(comp.id) + const compIdStr = String(comp.id) + const label = `${comp.vendorName}${comp.taxId}` + const label2 = `${comp.vendorName} ${comp.taxId}` + return ( + <CommandItem + key={comp.id} + value={label2} + onSelect={() => { + // 회사 ID를 number로 + field.onChange(Number(comp.id)) + setPopoverOpen(false) + + }} + > + {label2} + <Check + className={cn( + "ml-auto h-4 w-4", + selectedCompanyLabel === label2 + ? "opacity-100" + : "opacity-0" + )} + /> + </CommandItem> + ) + })} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + </FormControl> + <FormMessage /> + </FormItem> + ) + }} + /> + {/* Role (Vendor Admin) - 읽기 전용 */} + <FormField + control={form.control} + name="roles" // 실제 필드: z.array(z.string()) + render={({ field }) => ( + <FormItem> + <FormLabel>Role</FormLabel> + {/* UI에선 그냥 Vendor Admin이라고 표시만 (disabled) */} + <FormControl> + <Input + readOnly + disabled + value="Vendor Admin" + className="bg-gray-50 text-gray-500" + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* language Select */} + <FormField + control={form.control} + name="language" + render={({ field }) => ( + <FormItem> + <FormLabel>Language</FormLabel> + <FormControl> + <Select + onValueChange={field.onChange} + // 'value'로 현재 값 연결. defaultValue 대신 Controlled 컴포넌트로 + value={field.value} + > + <SelectTrigger> + <SelectValue placeholder="Select language" /> + </SelectTrigger> + <SelectContent> + {languageOptions.map((v, index) => ( + <SelectItem key={index} value={v.value}> + {v.label} + </SelectItem> + ))} + </SelectContent> + </Select> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + </div> + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => setOpen(false)} + disabled={isAddPending} + > + {isAddPending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + Cancel + </Button> + <Button type="submit" disabled={form.formState.isSubmitting || isAddPending}> + {isAddPending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + Create + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/admin-users/table/ausers-table-columns.tsx b/lib/admin-users/table/ausers-table-columns.tsx new file mode 100644 index 00000000..38281c7e --- /dev/null +++ b/lib/admin-users/table/ausers-table-columns.tsx @@ -0,0 +1,228 @@ +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { Ellipsis } from "lucide-react" +import { userRoles, type UserView } from "@/db/schema/users" + +import { formatDate } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header" +import { UserWithCompanyAndRoles } from "@/types/user" +import { getErrorMessage } from "@/lib/handle-error" + +import { modifiUser } from "@/lib/admin-users/service" +import { toast } from "sonner" + +import { userColumnsConfig } from "@/config/userColumnsConfig" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { MultiSelect } from "@/components/ui/multi-select" + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<UserView> | null>> +} + +/** + * tanstack table 컬럼 정의 (중첩 헤더 버전) + */ +export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<UserView>[] { + // ---------------------------------------------------------------- + // 1) select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef<UserView> = { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + size:40, + enableSorting: false, + enableHiding: false, + } + + // ---------------------------------------------------------------- + // 2) actions 컬럼 (Dropdown 메뉴) + // ---------------------------------------------------------------- + const actionsColumn: ColumnDef<UserView> = { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-8 p-0 data-[state=open]:bg-muted" + > + <Ellipsis className="size-4" aria-hidden="true" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-40"> + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "update" })} + > + Edit + </DropdownMenuItem> + + {/* <DropdownMenuSub> + <DropdownMenuSubTrigger>Roles</DropdownMenuSubTrigger> + <DropdownMenuSubContent> + <MultiSelect + defaultValue={row.original.roles} + options={userRoles.role.enumValues.map((role) => ({ + value: role, + label: role, + }))} + value={row.original.roles} + onValueChange={(value) => { + startUpdateTransition(() => { + + toast.promise( + modifiUser({ + id: row.original.user_id, + roles: value as ("admin"|"normal")[], + }), + { + loading: "Updating...", + success: "Roles updated", + error: (err) => getErrorMessage(err), + } + ); + }); + }} + + /> + </DropdownMenuSubContent> + </DropdownMenuSub> */} + + <DropdownMenuSeparator /> + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "delete" })} + > + Delete + <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ) + }, + size: 40, + } + + // ---------------------------------------------------------------- + // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 + // ---------------------------------------------------------------- + // 3-1) groupMap: { [groupName]: ColumnDef<User>[] } + const groupMap: Record<string, ColumnDef<UserView>[]> = {} + + userColumnsConfig.forEach((cfg) => { + // 만약 group가 없으면 "_noGroup" 처리 + const groupName = cfg.group || "_noGroup" + + if (!groupMap[groupName]) { + groupMap[groupName] = [] + } + + // child column 정의 + const childCol: ColumnDef<UserView> = { + accessorKey: cfg.id, + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title={cfg.label} /> + ), + meta: { + excelHeader: cfg.excelHeader, + group: cfg.group, + type: cfg.type, + }, + cell: ({ row, cell }) => { + + if (cfg.id === "created_at") { + const dateVal = cell.getValue() as Date + return formatDate(dateVal) + } + + if (cfg.id === "roles") { + const roleValues = row.original.roles; + return ( + <div className="flex flex-wrap gap-1"> + {roleValues.map((v) => ( + <Badge key={v} variant="outline"> + {v} + </Badge> + ))} + </div> + ); + } + + return row.getValue(cfg.id) ?? "" + }, + } + + groupMap[groupName].push(childCol) + }) + + // ---------------------------------------------------------------- + // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기 + // ---------------------------------------------------------------- + const nestedColumns: ColumnDef<UserView>[] = [] + + // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함 + // 여기서는 그냥 Object.entries 순서 + Object.entries(groupMap).forEach(([groupName, colDefs]) => { + if (groupName === "_noGroup") { + // 그룹 없음 → 그냥 최상위 레벨 컬럼 + nestedColumns.push(...colDefs) + } else { + // 상위 컬럼 + nestedColumns.push({ + id: groupName, + header: groupName, // "Basic Info", "Metadata" 등 + columns: colDefs, + }) + } + }) + + // ---------------------------------------------------------------- + // 4) 최종 컬럼 배열: select, nestedColumns, actions + // ---------------------------------------------------------------- + return [ + selectColumn, + ...nestedColumns, + actionsColumn, + ] +}
\ No newline at end of file diff --git a/lib/admin-users/table/ausers-table-floating-bar.tsx b/lib/admin-users/table/ausers-table-floating-bar.tsx new file mode 100644 index 00000000..ae950252 --- /dev/null +++ b/lib/admin-users/table/ausers-table-floating-bar.tsx @@ -0,0 +1,389 @@ +"use client" + +import * as React from "react" +import { userRoles, users, UserView, type User } from "@/db/schema/users" +import { SelectTrigger } from "@radix-ui/react-select" +import { type Table } from "@tanstack/react-table" +import { + ArrowUp, + CheckCircle2, + Download, + Loader, + Trash2, + X, Check +} from "lucide-react" +import { toast } from "sonner" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" +import { Portal } from "@/components/ui/portal" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, +} from "@/components/ui/select" +import { Separator } from "@/components/ui/separator" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" +import { Kbd } from "@/components/kbd" + +import { modifiUsers, getAllCompanies, removeUsers } from "@/lib//admin-users/service" +import { type Company } from "@/db/schema/companies" +import { + Popover, + PopoverTrigger, + PopoverContent, +} from "@/components/ui/popover" +import { + Command, + CommandInput, + CommandList, + CommandGroup, + CommandItem, + CommandEmpty, +} from "@/components/ui/command" +import { cn } from "@/lib/utils" +import { MultiSelect } from "@/components/ui/multi-select" +import { ActionConfirmDialog } from "@/components/ui/action-dialog" + +interface AusersTableFloatingBarProps { + table: Table<UserView> +} + + +export function AusersTableFloatingBar({ table }: AusersTableFloatingBarProps) { + const rows = table.getFilteredSelectedRowModel().rows + + const [isPending, startTransition] = React.useTransition() + const [action, setAction] = React.useState< + "update-company" | "update-roles" | "export" | "delete" + >() + const [companies, setCompanies] = React.useState<Company[]>([]) // 회사 목록 + + // Clear selection on Escape key press + React.useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + table.toggleAllRowsSelected(false) + } + } + + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [table]) + + React.useEffect(() => { + // 회사 목록 불러오기 (예시) + getAllCompanies().then((res) => { + setCompanies(res) + }) + }, []) + + const [popoverOpen, setPopoverOpen] = React.useState(false) + const [rolesPopoverOpen, setRolesPopoverOpen] = React.useState(false) + + + // 공용 confirm dialog state + const [confirmDialogOpen, setConfirmDialogOpen] = React.useState(false) + const [confirmProps, setConfirmProps] = React.useState<{ + title: string + description?: string + onConfirm: () => Promise<void> | void + }>({ + title: "", + description: "", + onConfirm: () => { }, + }) + + // 1) "삭제" Confirm 열기 + function handleDeleteConfirm() { + setAction("delete") + setConfirmProps({ + title: `Delete ${rows.length} user${rows.length > 1 ? "s" : ""}?`, + description: "This action cannot be undone.", + onConfirm: async () => { + startTransition(async () => { + const { error } = await removeUsers({ + ids: rows.map((row) => row.original.user_id), + }) + if (error) { + toast.error(error) + return + } + toast.success("Users deleted") + table.toggleAllRowsSelected(false) + setConfirmDialogOpen(false) + }) + }, + }) + setConfirmDialogOpen(true) + } + + // 2) "회사 업데이트"에서 회사 선택 시 → Confirm Dialog + function handleSelectCompany(comp: Company) { + setAction("update-company") + setPopoverOpen(false) + + // Confirm Dialog에 전달할 내용 + setConfirmProps({ + title: `Update ${rows.length} user${rows.length > 1 ? "s" : ""} to "${comp.name}"?`, + description: `TaxID: ${comp.taxID}. This action will overwrite their current company.`, + onConfirm: async () => { + startTransition(async () => { + const { error } = await modifiUsers({ + ids: rows.map((row) => row.original.user_id), + companyId: comp.id, + }) + if (error) { + toast.error(error) + return + } + toast.success("Users updated") + setConfirmDialogOpen(false) + }) + }, + }) + setConfirmDialogOpen(true) + } + + // 3) "역할 업데이트" MultiSelect 후 → Confirm Dialog + function handleSelectRoles(newRoles: string[]) { + setAction("update-roles") + setRolesPopoverOpen(false) + + setConfirmProps({ + title: `Update ${rows.length} user${rows.length > 1 ? "s" : ""} with roles: ${newRoles.join(", ")}?`, + description: "This action will override their current roles.", + onConfirm: async () => { + startTransition(async () => { + const { error } = await modifiUsers({ + ids: rows.map((row) => row.original.user_id), + roles: newRoles as ("admin" | "normal")[], + }) + if (error) { + toast.error(error) + return + } + toast.success("Users updated") + setConfirmDialogOpen(false) + }) + }, + }) + setConfirmDialogOpen(true) + } + + return ( + <Portal > + <div className="fixed inset-x-0 bottom-10 z-50 mx-auto w-fit px-2.5" style={{ bottom: '1.5rem' }}> + <div className="w-full overflow-x-auto"> + <div className="mx-auto flex w-fit items-center gap-2 rounded-md border bg-background p-2 text-foreground shadow"> + <div className="flex h-7 items-center rounded-md border border-dashed pl-2.5 pr-1"> + <span className="whitespace-nowrap text-xs"> + {rows.length} selected + </span> + <Separator orientation="vertical" className="ml-2 mr-1" /> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="icon" + className="size-5 hover:border" + onClick={() => table.toggleAllRowsSelected(false)} + > + <X className="size-3.5 shrink-0" aria-hidden="true" /> + </Button> + </TooltipTrigger> + <TooltipContent className="flex items-center border bg-accent px-2 py-1 font-semibold text-foreground dark:bg-zinc-900"> + <p className="mr-2">Clear selection</p> + <Kbd abbrTitle="Escape" variant="outline"> + Esc + </Kbd> + </TooltipContent> + </Tooltip> + </div> + <Separator orientation="vertical" className="hidden h-5 sm:block" /> + <div className="flex items-center gap-1.5"> + <Popover open={popoverOpen} onOpenChange={setPopoverOpen}> + + <Tooltip> + <PopoverTrigger asChild> + <TooltipTrigger asChild> + <Button + variant="secondary" + size="icon" + className="size-7 border data-[state=open]:bg-accent data-[state=open]:text-accent-foreground" + disabled={isPending} + > + {isPending && action === "update-company" ? ( + <Loader + className="size-3.5 animate-spin" + aria-hidden="true" + /> + ) : ( + <CheckCircle2 + className="size-3.5" + aria-hidden="true" + /> + )} + </Button> + </TooltipTrigger> + </PopoverTrigger> + <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> + <p>Update company</p> + </TooltipContent> + </Tooltip> + + <PopoverContent className="w-80 p-0"> + <Command> + <CommandInput placeholder="Search company..." className="h-9" /> + <CommandList> + <CommandEmpty>No company found.</CommandEmpty> + <CommandGroup> + {companies.map((comp) => { + const label = `${comp.name} (${comp.taxID})` + return ( + <CommandItem + key={comp.id} + value={label} + onSelect={() => handleSelectCompany(comp)} + > + {label} + </CommandItem> + ) + })} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + + <Popover open={rolesPopoverOpen} onOpenChange={setRolesPopoverOpen}> + + <Tooltip> + <PopoverTrigger asChild> + <TooltipTrigger asChild> + <Button + variant="secondary" + size="icon" + className="size-7 border data-[state=open]:bg-accent data-[state=open]:text-accent-foreground" + disabled={isPending} + > + {isPending && action === "update-roles" ? ( + <Loader + className="size-3.5 animate-spin" + aria-hidden="true" + /> + ) : ( + <ArrowUp className="size-3.5" aria-hidden="true" /> + + )} + </Button> + </TooltipTrigger> + </PopoverTrigger> + <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> + <p>Update roles</p> + </TooltipContent> + </Tooltip> + <PopoverContent> + <MultiSelect + defaultValue={["999999999"]} + options={[ + /* ... */ + { value: "999999999", label: "admin" } + ]} + onValueChange={(newRoles) => { + handleSelectRoles(newRoles) + }} + /> + </PopoverContent> + + </Popover> + + + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="secondary" + size="icon" + className="size-7 border" + onClick={() => { + setAction("export") + + startTransition(() => { + exportTableToExcel(table, { + excludeColumns: ["select", "actions"], + onlySelected: true, + }) + }) + }} + disabled={isPending} + > + {isPending && action === "export" ? ( + <Loader + className="size-3.5 animate-spin" + aria-hidden="true" + /> + ) : ( + <Download className="size-3.5" aria-hidden="true" /> + )} + </Button> + </TooltipTrigger> + <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> + <p>Export users</p> + </TooltipContent> + </Tooltip> + + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="secondary" + size="icon" + className="size-7 border" + onClick={handleDeleteConfirm} + disabled={isPending} + > + {isPending && action === "delete" ? ( + <Loader + className="size-3.5 animate-spin" + aria-hidden="true" + /> + ) : ( + <Trash2 className="size-3.5" aria-hidden="true" /> + )} + </Button> + </TooltipTrigger> + <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> + <p>Delete users</p> + </TooltipContent> + </Tooltip> + </div> + </div> + </div> + </div> + + {/* 공용 Confirm Dialog */} + <ActionConfirmDialog + open={confirmDialogOpen} + onOpenChange={setConfirmDialogOpen} + title={confirmProps.title} + description={confirmProps.description} + onConfirm={confirmProps.onConfirm} + isLoading={isPending && (action === "delete" || action === "update-company" || action === "update-roles")} + confirmLabel={ + action === "delete" + ? "Delete" + : action === "update-company" || action === "update-roles" + ? "Update" + : "Confirm" + } + confirmVariant={ + action === "delete" ? "destructive" : "default" + } + /> + </Portal> + ) +} diff --git a/lib/admin-users/table/ausers-table-toolbar-actions.tsx b/lib/admin-users/table/ausers-table-toolbar-actions.tsx new file mode 100644 index 00000000..5472c3ac --- /dev/null +++ b/lib/admin-users/table/ausers-table-toolbar-actions.tsx @@ -0,0 +1,118 @@ +"use client" + +import * as React from "react" +import { type Task } from "@/db/schema/tasks" +import { type Table } from "@tanstack/react-table" +import { Download, Upload } from "lucide-react" +import { toast } from "sonner" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" + +// 삭제, 추가 다이얼로그 +import { DeleteUsersDialog } from "./delete-ausers-dialog" +import { AddUserDialog } from "./add-ausers-dialog" + +// 만약 서버 액션이나 API 라우트를 이용해 업로드 처리한다면 import +import { importTasksExcel } from "@/lib/tasks/service" // 예시 +import { type UserView } from "@/db/schema/users" + +interface AdmUserTableToolbarActionsProps { + table: Table<UserView> +} + +export function AdmUserTableToolbarActions({ table }: AdmUserTableToolbarActionsProps) { + // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식 + const fileInputRef = React.useRef<HTMLInputElement>(null) + + // 파일이 선택되었을 때 처리 + async function onFileChange(event: React.ChangeEvent<HTMLInputElement>) { + const file = event.target.files?.[0] + if (!file) return + + // 파일 초기화 (동일 파일 재업로드 시에도 onChange가 트리거되도록) + event.target.value = "" + + // 서버 액션 or API 호출 + try { + // 예: 서버 액션 호출 + const { errorFile, errorMessage } = await importTasksExcel(file) + + if (errorMessage) { + toast.error(errorMessage) + } + if (errorFile) { + // 에러 엑셀을 다운로드 + const url = URL.createObjectURL(errorFile) + const link = document.createElement("a") + link.href = url + link.download = "errors.xlsx" + link.click() + URL.revokeObjectURL(url) + } else { + // 성공 + toast.success("Import success") + // 필요 시 revalidateTag("tasks") 등 + } + + } catch (err) { + toast.error("파일 업로드 중 오류가 발생했습니다.") + + } + } + + function handleImportClick() { + // 숨겨진 <input type="file" /> 요소를 클릭 + fileInputRef.current?.click() + } + + return ( + <div className="flex items-center gap-2"> + {/** 1) 선택된 로우가 있으면 삭제 다이얼로그 */} + {table.getFilteredSelectedRowModel().rows.length > 0 ? ( + <DeleteUsersDialog + users={table + .getFilteredSelectedRowModel() + .rows.map((row) => row.original)} + onSuccess={() => table.toggleAllRowsSelected(false)} + /> + ) : null} + + {/** 2) 새 Task 추가 다이얼로그 */} + <AddUserDialog /> + + {/** 3) Import 버튼 (파일 업로드) */} + <Button variant="outline" size="sm" className="gap-2" onClick={handleImportClick}> + <Upload className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Import</span> + </Button> + {/* + 실제로는 숨겨진 input과 연결: + - accept=".xlsx,.xls" 등으로 Excel 파일만 업로드 허용 + */} + <input + ref={fileInputRef} + type="file" + accept=".xlsx,.xls" + className="hidden" + onChange={onFileChange} + /> + + {/** 4) Export 버튼 */} + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(table, { + filename: "tasks", + excludeColumns: ["select", "actions"], + }) + } + className="gap-2" + > + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Export</span> + </Button> + </div> + ) +}
\ No newline at end of file diff --git a/lib/admin-users/table/ausers-table.tsx b/lib/admin-users/table/ausers-table.tsx new file mode 100644 index 00000000..ed575e75 --- /dev/null +++ b/lib/admin-users/table/ausers-table.tsx @@ -0,0 +1,180 @@ +"use client" + +import * as React from "react" +import { userRoles , type UserView} from "@/db/schema/users" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" + +import { toSentenceCase } from "@/lib/utils" +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import { DataTableToolbar } from "@/components/data-table/data-table-toolbar" + +import type { + getUserCountGroupByCompany, + getUserCountGroupByRole, + getUsers, getAllCompanies, + getAllRoles +} from "@/lib//admin-users/service" +import { getColumns } from "./ausers-table-columns" +import { AdmUserTableToolbarActions } from "./ausers-table-toolbar-actions" +import { DeleteUsersDialog } from "./delete-ausers-dialog" +import { AusersTableFloatingBar } from "./ausers-table-floating-bar" +import { UpdateAuserSheet } from "./update-auser-sheet" + +interface UsersTableProps { + promises: Promise< + [ + Awaited<ReturnType<typeof getUsers>>, + Record<number, number>, + Record<number, number>, + Awaited<ReturnType<typeof getAllCompanies>>, + Awaited<ReturnType<typeof getAllRoles>> + ] + > +} +type RoleCounts = Record<string, number> + +export function AdmUserTable({ promises }: UsersTableProps) { + + const [{ data, pageCount }, companyCounts,roleCountsRaw, companies, roles] = + React.use(promises) + + + const [rowAction, setRowAction] = + React.useState<DataTableRowAction<UserView> | null>(null) + + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + const roleCounts = roleCountsRaw as RoleCounts + + + /** + * This component can render either a faceted filter or a search filter based on the `options` prop. + * + * @prop options - An array of objects, each representing a filter option. If provided, a faceted filter is rendered. If not, a search filter is rendered. + * + * Each `option` object has the following properties: + * @prop {string} label - The label for the filter option. + * @prop {string} value - The value for the filter option. + * @prop {React.ReactNode} [icon] - An optional icon to display next to the label. + * @prop {boolean} [withCount] - An optional boolean to display the count of the filter option. + */ + const filterFields: DataTableFilterField<UserView>[] = [ + { + id: "user_email", + label: "Email", + placeholder: "Filter email...", + }, + + ] + + /** + * Advanced filter fields for the data table. + * These fields provide more complex filtering options compared to the regular filterFields. + * + * Key differences from regular filterFields: + * 1. More field types: Includes 'text', 'multi-select', 'date', and 'boolean'. + * 2. Enhanced flexibility: Allows for more precise and varied filtering options. + * 3. Used with DataTableAdvancedToolbar: Enables a more sophisticated filtering UI. + * 4. Date and boolean types: Adds support for filtering by date ranges and boolean values. + */ + const advancedFilterFields: DataTableAdvancedFilterField<UserView>[] = [ + { + id: "user_name", + label: "User Name", + type: "text", + }, + { + id: "user_email", + label: "Email", + type: "text", + }, + { + id: "company_name", + label: "Company", + type: "multi-select", + options: companies.map((comp) => ({ + label: comp.vendorName, + value: comp.vendorName, + count: companyCounts[comp.id] + })), + }, + + { + id: "roles", + label: "Roles", + type: "multi-select", + options: roles.map((role) => { + return { + label: toSentenceCase(role.name), + value: role.id, + count: roleCounts[role.id], // 이 값이 undefined인지 확인 + }; + }), + }, + { + id: "created_at", + label: "Created at", + type: "date", + }, + ] + + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "created_at", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => `${originalRow.user_id}`, + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + <DataTable + table={table} + floatingBar={<AusersTableFloatingBar table={table}/>} + + > + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <AdmUserTableToolbarActions table={table} /> + </DataTableAdvancedToolbar> + + </DataTable> + + <DeleteUsersDialog + open={rowAction?.type === "delete"} + onOpenChange={() => setRowAction(null)} + users={rowAction?.row.original ? [rowAction?.row.original] : []} + showTrigger={false} + onSuccess={() => rowAction?.row.toggleSelected(false)} + /> + + <UpdateAuserSheet + open={rowAction?.type === "update"} + onOpenChange={() => setRowAction(null)} + user={rowAction?.row.original ?? null} + /> + + </> + ) +} diff --git a/lib/admin-users/table/delete-ausers-dialog.tsx b/lib/admin-users/table/delete-ausers-dialog.tsx new file mode 100644 index 00000000..0699bb95 --- /dev/null +++ b/lib/admin-users/table/delete-ausers-dialog.tsx @@ -0,0 +1,149 @@ +"use client" + +import * as React from "react" +import { type Row } from "@tanstack/react-table" +import { Loader, Trash } from "lucide-react" +import { toast } from "sonner" + +import { useMediaQuery } from "@/hooks/use-media-query" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer" + +import { removeUsers } from "@/lib//admin-users/service" +import { type UserView } from "@/db/schema/users" + +interface DeleteUsersDialogProps + extends React.ComponentPropsWithoutRef<typeof Dialog> { + users: Row<UserView>["original"][] + showTrigger?: boolean + onSuccess?: () => void +} + +export function DeleteUsersDialog({ + users, + showTrigger = true, + onSuccess, + ...props +}: DeleteUsersDialogProps) { + const [isDeletePending, startDeleteTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + + function onDelete() { + startDeleteTransition(async () => { + const { error } = await removeUsers({ + ids: users.map((user) => Number(user.user_id)), + }) + + if (error) { + toast.error(error) + return + } + + props.onOpenChange?.(false) + toast.success("Users deleted") + onSuccess?.() + }) + } + + if (isDesktop) { + return ( + <Dialog {...props}> + {showTrigger ? ( + <DialogTrigger asChild> + <Button variant="outline" size="sm"> + <Trash className="mr-2 size-4" aria-hidden="true" /> + Delete ({users.length}) + </Button> + </DialogTrigger> + ) : null} + <DialogContent> + <DialogHeader> + <DialogTitle>Are you absolutely sure?</DialogTitle> + <DialogDescription> + This action cannot be undone. This will permanently delete your{" "} + <span className="font-medium">{users.length}</span> + {users.length === 1 ? " user" : " users"} from our servers. + </DialogDescription> + </DialogHeader> + <DialogFooter className="gap-2 sm:space-x-0"> + <DialogClose asChild> + <Button variant="outline">Cancel</Button> + </DialogClose> + <Button + aria-label="Delete selected rows" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending} + > + {isDeletePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + Delete + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) + } + + return ( + <Drawer {...props}> + {showTrigger ? ( + <DrawerTrigger asChild> + <Button variant="outline" size="sm"> + <Trash className="mr-2 size-4" aria-hidden="true" /> + Delete ({users.length}) + </Button> + </DrawerTrigger> + ) : null} + <DrawerContent> + <DrawerHeader> + <DrawerTitle>Are you absolutely sure?</DrawerTitle> + <DrawerDescription> + This action cannot be undone. This will permanently delete your{" "} + <span className="font-medium">{users.length}</span> + {users.length === 1 ? " user" : " users"} from our servers. + </DrawerDescription> + </DrawerHeader> + <DrawerFooter className="gap-2 sm:space-x-0"> + <DrawerClose asChild> + <Button variant="outline">Cancel</Button> + </DrawerClose> + <Button + aria-label="Delete selected rows" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending} + > + {isDeletePending && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + Delete + </Button> + </DrawerFooter> + </DrawerContent> + </Drawer> + ) +} diff --git a/lib/admin-users/table/update-auser-sheet.tsx b/lib/admin-users/table/update-auser-sheet.tsx new file mode 100644 index 00000000..ddf1f932 --- /dev/null +++ b/lib/admin-users/table/update-auser-sheet.tsx @@ -0,0 +1,225 @@ +"use client" + +import * as React from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { Loader } from "lucide-react" +import { useForm } from "react-hook-form" +import { toast } from "sonner" + +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { + Select, + SelectTrigger, + SelectContent, + SelectItem, + SelectValue, + SelectGroup, +} from "@/components/ui/select" +// import your MultiSelect or other role selection +import { MultiSelect } from "@/components/ui/multi-select" + +import { userRoles, type UserView } from "@/db/schema/users" +import { updateUserSchema, type UpdateUserSchema } from "@/lib/admin-users/validations" +import { modifiUser } from "@/lib/admin-users/service" + +export interface UpdateAuserSheetProps + extends React.ComponentPropsWithRef<typeof Sheet> { + user: UserView | null +} + +const languageOptions = [ + { value: "ko", label: "한국어" }, + { value: "en", label: "English" }, +] + + +export function UpdateAuserSheet({ user, ...props }: UpdateAuserSheetProps) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + + // 1) RHF 설정 + const form = useForm<UpdateUserSchema & { language?: string }>({ + resolver: zodResolver(updateUserSchema), + defaultValues: { + name: user?.user_name ?? "", + email: user?.user_email ?? "", + companyId: user?.company_id ?? null, + roles: user?.roles ?? [], + language:'en', + }, + }) + + // 2) user prop 바뀔 때마다 form.reset + React.useEffect(() => { + if (user) { + form.reset({ + name: user.user_name, + email: user.user_email, + companyId: user.company_id, + roles: user.roles, + }) + } + }, [user, form]) + + + // 3) onSubmit + async function onSubmit(input: UpdateUserSchema & { language?: string }) { + startUpdateTransition(async () => { + if (!user) return + + const { error } = await modifiUser({ + id: user.user_id, // user.userId + ...input, + }) + + if (error) { + toast.error(error) + return + } + + // 성공 시 + form.reset() + props.onOpenChange?.(false) + toast.success("User updated successfully!") + }) + } + + return ( + <Sheet {...props}> + <SheetContent className="flex flex-col gap-6 sm:max-w-md"> + <SheetHeader className="text-left"> + <SheetTitle>Update user</SheetTitle> + <SheetDescription> + Update the user details and save the changes + </SheetDescription> + </SheetHeader> + + {/* 4) RHF Form */} + <Form {...form}> + <form + onSubmit={form.handleSubmit(onSubmit)} + className="flex flex-col gap-4" + > + {/* name */} + <FormField + control={form.control} + name="name" + render={({ field }) => ( + <FormItem> + <FormLabel>User Name</FormLabel> + <FormControl> + <Input placeholder="e.g. dujin" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* email */} + <FormField + control={form.control} + name="email" + render={({ field }) => ( + <FormItem> + <FormLabel>Email</FormLabel> + <FormControl> + <Input type="email" placeholder="user@example.com" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* roles */} + <FormField + control={form.control} + name="roles" + render={({ field }) => ( + <FormItem> + <FormLabel>Roles</FormLabel> + <FormControl> + <MultiSelect + // 예: userRoles.role.enumValues = ["admin","normal"] + defaultValue={form?.getValues().roles} + options={[ + { value: "999999999", label: "admin" } + ]} + value={field.value} + onValueChange={(vals) => field.onChange(vals)} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="language" + render={({ field }) => ( + <FormItem> + <FormLabel>Language</FormLabel> + <FormControl> + <Select + onValueChange={field.onChange} + // 'value'로 현재 값 연결. defaultValue 대신 Controlled 컴포넌트로 + value={field.value} + > + <SelectTrigger> + <SelectValue placeholder="Select language" /> + </SelectTrigger> + <SelectContent> + {languageOptions.map((v, index) => ( + <SelectItem key={index} value={v.value}> + {v.label} + </SelectItem> + ))} + </SelectContent> + </Select> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 5) Footer: Cancel, Save */} + <SheetFooter className="gap-2 pt-2 sm:space-x-0"> + <SheetClose asChild> + <Button type="button" variant="outline"> + Cancel + </Button> + </SheetClose> + + <Button type="submit" disabled={isUpdatePending}> + {isUpdatePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + Save + </Button> + </SheetFooter> + </form> + </Form> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/lib/admin-users/validations.ts b/lib/admin-users/validations.ts new file mode 100644 index 00000000..e505067d --- /dev/null +++ b/lib/admin-users/validations.ts @@ -0,0 +1,65 @@ +import { userRoles, users, type UserView } from "@/db/schema/users"; +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, +} from "nuqs/server" +import * as z from "zod" +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" +import { checkEmailExists } from "./service"; + + + +export const searchParamsCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault( + [] + ), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser<UserView>().withDefault([ + { id: "created_at", desc: true }, + ]), + email: parseAsString.withDefault(""), + // advanced filter + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), + +}) + +export const createUserSchema = z.object({ + email: z + .string() + .email() + .refine( + async (email) => { + // 1) DB 조회해서 이미 같은 email이 있으면 false 반환 + const isUsed = await checkEmailExists(email); + return !isUsed; + }, + { + message: "This email is already in use", + } + ), + name: z.string().min(1), // 최소 길이 1 + domain: z.enum(users.domain.enumValues), // "evcp" | "partners" + companyId: z.number().nullable().optional(), // number | null | undefined + roles:z.array(z.string()).min(1, "At least one role must be selected"), + language: z.enum(["ko", "en"]).optional(), + +}); + +export const updateUserSchema = z.object({ + name: z.string().optional(), + email: z.string().email().optional(), + domain: z.enum(users.domain.enumValues).optional(), + companyId: z.number().nullable().optional(), + roles: z.array(z.string()).optional(), + language: z.enum(["ko", "en"]).optional(), + +}); +export type GetUsersSchema = Awaited<ReturnType<typeof searchParamsCache.parse>> +export type CreateUserSchema = z.infer<typeof createUserSchema> +export type UpdateUserSchema = z.infer<typeof updateUserSchema> diff --git a/lib/compose-refs.ts b/lib/compose-refs.ts new file mode 100644 index 00000000..bed48a40 --- /dev/null +++ b/lib/compose-refs.ts @@ -0,0 +1,38 @@ +/** + * @see https://github.com/radix-ui/primitives/blob/main/packages/react/compose-refs/src/composeRefs.tsx + */ + +import * as React from "react" + +type PossibleRef<T> = React.Ref<T> | undefined + +/** + * Set a given ref to a given value + * This utility takes care of different types of refs: callback refs and RefObject(s) + */ +function setRef<T>(ref: PossibleRef<T>, value: T) { + if (typeof ref === "function") { + ref(value) + } else if (ref !== null && ref !== undefined) { + ;(ref as React.MutableRefObject<T>).current = value + } +} + +/** + * A utility to compose multiple refs together + * Accepts callback refs and RefObject(s) + */ +function composeRefs<T>(...refs: PossibleRef<T>[]) { + return (node: T) => refs.forEach((ref) => setRef(ref, node)) +} + +/** + * A custom hook that composes multiple refs + * Accepts callback refs and RefObject(s) + */ +function useComposedRefs<T>(...refs: PossibleRef<T>[]) { + // eslint-disable-next-line react-hooks/exhaustive-deps + return React.useCallback(composeRefs(...refs), refs) +} + +export { composeRefs, useComposedRefs } diff --git a/lib/constants.ts b/lib/constants.ts new file mode 100644 index 00000000..c95834ad --- /dev/null +++ b/lib/constants.ts @@ -0,0 +1,3 @@ +export const unknownError = "An unknown error occurred. Please try again later." + +export const databasePrefix = "shadcn" diff --git a/lib/data-table.ts b/lib/data-table.ts new file mode 100644 index 00000000..4fed7b9b --- /dev/null +++ b/lib/data-table.ts @@ -0,0 +1,181 @@ +import type { ColumnType, Filter, FilterOperator, } from "@/types/table" +import { type Column } from "@tanstack/react-table" + +import { dataTableConfig } from "@/config/data-table" +import { FilterFn, Row } from "@tanstack/react-table" + +/** + * Generate common pinning styles for a table column. + * + * This function calculates and returns CSS properties for pinned columns in a data table. + * It handles both left and right pinning, applying appropriate styles for positioning, + * shadows, and z-index. The function also considers whether the column is the last left-pinned + * or first right-pinned column to apply specific shadow effects. + * + * @param options - The options for generating pinning styles. + * @param options.column - The column object for which to generate styles. + * @param options.withBorder - Whether to show a box shadow between pinned and scrollable columns. + * @returns A React.CSSProperties object containing the calculated styles. + */ +export function getCommonPinningStyles<TData>({ + column, + withBorder = false, +}: { + column: Column<TData> + /** + * Show box shadow between pinned and scrollable columns. + * @default false + */ + withBorder?: boolean +}): React.CSSProperties { + const isPinned = column.getIsPinned() + const isLastLeftPinnedColumn = + isPinned === "left" && column.getIsLastColumn("left") + const isFirstRightPinnedColumn = + isPinned === "right" && column.getIsFirstColumn("right") + + return { + boxShadow: withBorder + ? isLastLeftPinnedColumn + ? "-4px 0 4px -4px hsl(var(--border)) inset" + : isFirstRightPinnedColumn + ? "4px 0 4px -4px hsl(var(--border)) inset" + : undefined + : undefined, + left: isPinned === "left" ? `${column.getStart("left")}px` : undefined, + right: isPinned === "right" ? `${column.getAfter("right")}px` : undefined, + opacity: isPinned ? 0.97 : 1, + position: isPinned ? "sticky" : "relative", + background: isPinned ? "hsl(var(--background))" : "hsl(var(--background))", + width: column.getSize(), + zIndex: isPinned ? 1 : 0, + } +} + +/** + * Determine the default filter operator for a given column type. + * + * This function returns the most appropriate default filter operator based on the + * column's data type. For text columns, it returns 'iLike' (case-insensitive like), + * while for all other types, it returns 'eq' (equality). + * + * @param columnType - The type of the column (e.g., 'text', 'number', 'date', etc.). + * @returns The default FilterOperator for the given column type. + */ +export function getDefaultFilterOperator( + columnType: ColumnType +): FilterOperator { + if (columnType === "text") { + return "iLike" + } + + return "eq" +} + +/** + * Retrieve the list of applicable filter operators for a given column type. + * + * This function returns an array of filter operators that are relevant and applicable + * to the specified column type. It uses a predefined mapping of column types to + * operator lists, falling back to text operators if an unknown column type is provided. + * + * @param columnType - The type of the column for which to get filter operators. + * @returns An array of objects, each containing a label and value for a filter operator. + */ +export function getFilterOperators(columnType: ColumnType) { + const operatorMap: Record< + ColumnType, + { label: string; value: FilterOperator }[] + > = { + text: dataTableConfig.textOperators, + number: dataTableConfig.numericOperators, + select: dataTableConfig.selectOperators, + "multi-select": dataTableConfig.selectOperators, + boolean: dataTableConfig.booleanOperators, + date: dataTableConfig.dateOperators, + } + + return operatorMap[columnType] ?? dataTableConfig.textOperators +} + +/** + * Filters out invalid or empty filters from an array of filters. + * + * This function processes an array of filters and returns a new array + * containing only the valid filters. A filter is considered valid if: + * - It has an 'isEmpty' or 'isNotEmpty' operator, or + * - Its value is not empty (for array values, at least one element must be present; + * for other types, the value must not be an empty string, null, or undefined) + * + * @param filters - An array of Filter objects to be validated. + * @returns A new array containing only the valid filters. + */ +export function getValidFilters<TData>( + filters: Filter<TData>[] +): Filter<TData>[] { + return filters?.filter( + (filter) => + filter.operator === "isEmpty" || + filter.operator === "isNotEmpty" || + (Array.isArray(filter.value) + ? filter.value.length > 0 + : filter.value !== "" && + filter.value !== null && + filter.value !== undefined) + ) +} + +interface NumericFilterValue { + operator: string + inputValue?: number +} + + +export const numericFilter: FilterFn<any> = ( + row: Row<any>, + columnId: string, + filterValue: NumericFilterValue +) => { + const rowValue = row.getValue(columnId) + + // handle "isEmpty" / "isNotEmpty" + if (filterValue.operator === "isEmpty") { + return rowValue == null || rowValue === "" + } else if (filterValue.operator === "isNotEmpty") { + return !(rowValue == null || rowValue === "") + } + + // parse rowValue → numeric + const numericRowVal = + typeof rowValue === "number" ? rowValue : parseFloat(String(rowValue)) + + if (isNaN(numericRowVal)) { + // rowValue not a number + return false + } + + // parse filterValue.inputValue + const filterNum = filterValue.inputValue + if (filterNum == null || isNaN(filterNum)) { + // if user didn’t actually type a number, match everything or nothing (your choice) + return true + } + + // compare based on operator + switch (filterValue.operator) { + case "eq": + return numericRowVal === filterNum + case "ne": + return numericRowVal !== filterNum + case "lt": + return numericRowVal < filterNum + case "lte": + return numericRowVal <= filterNum + case "gt": + return numericRowVal > filterNum + case "gte": + return numericRowVal >= filterNum + default: + return true + } +}
\ No newline at end of file diff --git a/lib/docuSign/docuSignFns.ts b/lib/docuSign/docuSignFns.ts new file mode 100644 index 00000000..87977a0b --- /dev/null +++ b/lib/docuSign/docuSignFns.ts @@ -0,0 +1,383 @@ +"use server"; + +import docusign from "docusign-esign"; +import fs from "fs"; +import path from "path"; +import jwtConfig from "./jwtConfig/jwtConfig.json"; +import dayjs from "dayjs"; +import { ContractInfo, ContractorInfo } from "./types"; + +const SCOPES = ["signature", "impersonation"]; + +//DocuSign 인증 정보 +async function authenticate(): Promise< + | undefined + | { + accessToken: string; + apiAccountId: string; + basePath: string; + } +> { + const jwtLifeSec = 10 * 60; + const dsApi = new docusign.ApiClient(); + dsApi.setOAuthBasePath(jwtConfig.dsOauthServer.replace("https://", "")); + const privateKeyPath = path.resolve( + process.cwd(), + jwtConfig.privateKeyLocation + ); + + let rsaKey: Buffer = fs.readFileSync(privateKeyPath); + + try { + const results = await dsApi.requestJWTUserToken( + jwtConfig.dsJWTClientId, + jwtConfig.impersonatedUserGuid, + SCOPES, + rsaKey, + jwtLifeSec + ); + const accessToken = results.body.access_token; + + const userInfoResults = await dsApi.getUserInfo(accessToken); + let userInfo = userInfoResults.accounts.find( + (account: Partial<{ isDefault: string }>) => account.isDefault === "true" + ); + + return { + accessToken: results.body.access_token, + apiAccountId: userInfo.accountId, + basePath: `${userInfo.baseUri}/restapi`, + }; + } catch (e) { + console.error("❌ 인증 실패:", e); + } +} + +async function getSignerId( + basePath: string, + accountId: string, + accessToken: string, + envelopeId: string, + roleName: string +): Promise<string | null> { + const apiClient = new docusign.ApiClient(); + apiClient.setBasePath(basePath); + apiClient.addDefaultHeader("Authorization", "Bearer " + accessToken); + + const envelopesApi = new docusign.EnvelopesApi(apiClient); + + try { + const recipients = await envelopesApi.listRecipients(accountId, envelopeId); + + const singers = recipients?.signers ?? []; + + // 🔹 특정 서명자(Role Name 기준)의 Recipient ID 찾기 + const signer = singers.find((s) => s.roleName === roleName); + if (!signer) { + console.error("❌ 해당 Role Name을 가진 서명자를 찾을 수 없습니다."); + return null; + } + + return signer.recipientId as string; + } catch (error) { + console.error("❌ 서명자 ID 조회 실패:", error); + return null; + } +} + +//계약서 서명 요청 +export async function requestContractSign( + contractTemplateId: string, + contractInfo: ContractInfo[], + subcontractorinfo: ContractorInfo, + contractorInfo: ContractorInfo, + ccInfo: ContractorInfo[], + brandId: string | undefined = undefined +): Promise< + Partial<{ + result: boolean; + envelopeId: string; + error: any; + }> +> { + let accountInfo = await authenticate(); + if (accountInfo) { + const { accessToken, basePath, apiAccountId } = accountInfo; + const { + email: subEmail, + name: subConName, + roleName: subRoleName, + } = subcontractorinfo; + + const { + email: conEmail, + name: conName, + roleName: roleName, + } = contractorInfo; + + const apiClient = new docusign.ApiClient(); + apiClient.setBasePath(basePath); + apiClient.addDefaultHeader("Authorization", "Bearer " + accessToken); + const envelopesApi = new docusign.EnvelopesApi(apiClient); + + const signer1: docusign.TemplateRole = { + email: subEmail, + name: subConName, + roleName: subRoleName, + }; + + const signer1Tabs: docusign.Tabs = { + textTabs: [ + ...contractInfo.map((c): docusign.Text => { + const textField: docusign.Text = { + tabLabel: c.tabLabel, + value: c.value, + locked: "true", + }; + return textField; + }), + ], + }; + + const signer2: docusign.TemplateRole = { + email: conEmail, + name: conName, + roleName: roleName, + }; + + const signer2Tabs: docusign.Tabs = { + dateSignedTabs: [ + { + tabLabel: "contract_complete_date", + }, + ], + }; + + signer1.tabs = signer1Tabs; + signer2.tabs = signer2Tabs; + + const envelopeDefinition: docusign.EnvelopeDefinition = { + templateId: contractTemplateId, + templateRoles: [signer1, signer2, ...ccInfo], // 두 명의 서명자 추가 + status: "sent", // 즉시 발송 + }; + + if (brandId) { + envelopeDefinition.brandId = brandId; + } + + try { + let envelopeSummary = await envelopesApi.createEnvelope(apiAccountId, { + envelopeDefinition, + }); + + // console.log("✅ 서명 요청 완료, Envelope ID:", envelopeSummary); + return { + result: true, + envelopeId: envelopeSummary.envelopeId, + }; + } catch (error) { + console.dir(error); + return { + result: false, + error, + }; + } + } else { + return { + result: false, + }; + } +} + +//서명된 계약서 다운로드 +export async function downloadContractFile(envelopeId: string): Promise< + Partial<{ + result: boolean; + fileName: string; + buffer: Buffer; + envelopeId: string; + error: any; + }> +> { + let accountInfo = await authenticate(); + + if (accountInfo) { + const { accessToken, apiAccountId, basePath } = accountInfo; + + const apiClient = new docusign.ApiClient(); + apiClient.setBasePath(basePath); + apiClient.addDefaultHeader("Authorization", "Bearer " + accessToken); + + const envelopesApi = new docusign.EnvelopesApi(apiClient); + + try { + //Document ID 등 파일 정보를 호출 + const response = await envelopesApi.listDocuments( + apiAccountId, + envelopeId, + null + ); + + const { envelopeDocuments } = response || { envelopeDocuments: [] }; + + if (Array.isArray(envelopeDocuments) && envelopeDocuments.length > 0) { + const { documentId, name } = envelopeDocuments[0] as { + documentId: string; + name: string; + }; + + //Document Buffer 호출 + const downloadFile = await envelopesApi.getDocument( + apiAccountId, + envelopeId, + documentId, + {} + ); + + if (documentId && documentId !== "certificate") { + const bufferData: Buffer = downloadFile as unknown as Buffer; + return { + result: true, + fileName: name, + buffer: bufferData, + envelopeId, + }; + } + } + + return { + result: false, + }; + } catch (error) { + return { + result: false, + error, + }; + } + } else { + return { + result: false, + }; + } +} + +//최종 서명 날짜 찾기 +export async function findContractCompleteTime( + envelopeId: string, + lastSignerRoleName: string +): Promise<{ + completedDateTime: string; + year: string; + month: string; + day: string; + time: string; +} | null> { + let accountInfo = await authenticate(); + + if (!accountInfo) { + console.error("❌ 인증 실패: API 요청을 중단합니다."); + return null; + } + + const { accessToken, apiAccountId: accountId, basePath } = accountInfo; + + const apiClient = new docusign.ApiClient(); + apiClient.setBasePath(basePath); + apiClient.addDefaultHeader("Authorization", "Bearer " + accessToken); + + const envelopesApi = new docusign.EnvelopesApi(apiClient); + + try { + const envelope = await envelopesApi.getEnvelope(accountId, envelopeId); + if (!envelope.completedDateTime) { + console.error("❌ 서명 완료 날짜가 없습니다."); + return null; + } + + // 🔹 `SIGNER_ID` 가져오기 + const signerId = await getSignerId( + basePath, + accountId, + accessToken, + envelopeId, + lastSignerRoleName + ); + if (!signerId) { + console.error("❌ 서명자 ID를 찾을 수 없습니다."); + return null; + } + + const completedDate = dayjs(envelope.completedDateTime); + const year = completedDate.format("YYYY").toString(); + const month = completedDate.format("MM").toString(); + const day = completedDate.format("DD").toString(); + const time = completedDate.format("HH:mm").toString(); + + return { + completedDateTime: envelope.completedDateTime, + year, + month, + day, + time, + }; + } catch (error) { + console.error("❌ 서명 완료 후 날짜 추가 실패:", error); + return null; + } +} + +export async function getRecipients( + envelopeId: string, + recipientId: string +): Promise<{ result: boolean; message?: string }> { + try { + let accountInfo = await authenticate(); + + if (!accountInfo) { + console.error("❌ 인증 실패: API 요청을 중단합니다."); + return { + result: false, + message: "인증 실패: API 요청을 중단합니다.", + }; + } + + const { accessToken, apiAccountId: accountId, basePath } = accountInfo; + + const apiClient = new docusign.ApiClient(); + apiClient.setBasePath(basePath); + apiClient.addDefaultHeader("Authorization", "Bearer " + accessToken); + + const envelopesApi = new docusign.EnvelopesApi(apiClient); + + const response = await envelopesApi.listRecipients(accountId, envelopeId); + + const singers: { [key: string]: any }[] = response?.signers ?? []; + + // 🔹 특정 서명자(Role Name 기준)의 Recipient ID 찾기 + const signer = singers.find((s) => s.recipientId === recipientId); + if (!signer) { + console.error("❌ 해당 Role Name을 가진 서명자를 찾을 수 없습니다."); + return { + result: false, + message: "해당 Recipient id를 가진 서명자를 찾을 수 없습니다.", + }; + } + + const { autoRespondedReason, status } = signer; + + if (autoRespondedReason || status === "status") { + return { + result: false, + message: autoRespondedReason, + }; + } + + return { + result: true, + }; + } catch (error) { + console.error("Error retrieving recipients:", error); + return { result: false, message: (error as Error).message }; + } +} diff --git a/lib/docuSign/jwtConfig/README.md b/lib/docuSign/jwtConfig/README.md new file mode 100644 index 00000000..7c997d07 --- /dev/null +++ b/lib/docuSign/jwtConfig/README.md @@ -0,0 +1,54 @@ +# DocuSign + +## DocuSign Contract Template + +### DocuSign Delveloper Account + +1. ID: kiman.kim@dtsolution.co.kr +2. PW: rlaks!153 + +### jwtConfig.json + +1. DocuSign Developer 로그인 +2. DocuSign Developer Admin 메뉴 이동 +3. DocuSign 좌측 메뉴 바에서 INTERGRATIONS > Apps and Keys 이동 + +```jwtConfig.json +{ + //Add App and Intergraion Key 시 private.key 파일 생성 (처음 key를 만들때만 저장 가능함.) + "privateKeyLocation": private.key 파일 경로, + "dsJWTClientId": Apps and Intergration Keys 내 Intergration Kzey, + "impersonatedUserGuid": My Account Information 내 User ID, + //개발환경: https://account-d.docusign.com + //운영환경: https://account.docusign.com + "dsOauthServer": "https://account-d.docusign.com" +} +``` + +### DocuSign Web Hook + +1. DocuSign Developer 로그인 +2. DocuSign Developer Admin 메뉴 이동 +3. DocuSign 좌측 메뉴 바에서 INTERGRATIONS > Connect 이동 +4. Add Configuration > Custom +5. Web Hook Url 입력 +6. Trigger Events + 6.1. Envelope Signed/Completed - Check + 6.2. Envelope Declined - Check + 6.3. Recipient Sent - Check + 6.4. Recipient Delivered - Check + 6.5. Recipient Signed/Completed - Check + 6.6. Recipient Declined - Check + +### DocuSign Mail Sender Info Change + +1. DocuSign Developer 로그인 +2. 우측 상단 유저 아이콘 클릭 후 Manage Profile Menu로 이동 +3. My Profile에서 Name 변경 + +### DocuSign Mail Templete Change + +1. DocuSign Developer 로그인 +2. DocuSign Developer Admin 메뉴 이동 +3. DocuSign 좌측 메뉴 바에서 ACCOUNT > Brands 이동 +4. 사용하고자 하는 Brand 제작 후 BrandId 사용 diff --git a/lib/docuSign/jwtConfig/jwtConfig.json b/lib/docuSign/jwtConfig/jwtConfig.json new file mode 100644 index 00000000..756ca9dd --- /dev/null +++ b/lib/docuSign/jwtConfig/jwtConfig.json @@ -0,0 +1,6 @@ +{ + "dsJWTClientId": "4ecf089f-9134-4c6c-9657-d8f8c41b5965", + "impersonatedUserGuid": "de8ef3a2-9498-4855-a571-249a774a3905", + "privateKeyLocation": "./lib/docuSign/jwtConfig/private.key", + "dsOauthServer": "https://account-d.docusign.com" +} diff --git a/lib/docuSign/jwtConfig/private.key b/lib/docuSign/jwtConfig/private.key new file mode 100644 index 00000000..73c4291a --- /dev/null +++ b/lib/docuSign/jwtConfig/private.key @@ -0,0 +1,29 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAnnjspqTabuuPLPi9Iga8U/chJRNmyr1PTbJC/Il0jse4ps/C +KGQdmVOsDzPW//dopMLVc5OmJ7I3y7lw2+TuJ0G7Ip7s6epV2dzqH9aA/yvHDwvj +2W9ZRH8pNx5AjNDCscwBF3NCK8CoGqK3+ukvuErVK8XQHnzOtAF2uyd2JLodT0fE +I+uyvIL1E5pzU5zHzxHoWCsrjKAVaHhWUiTP0migFYrMBMVWC30slvhrNg1qc4uT +Of3rkOGAUK+MFqCbaUm4qKBest9hDgSSw1h8Wv3cKD90KlRgZRSLSRxFwxzhj0ft +1ip+JIc8dLcax1+xhX0dKBW2GARchojxEAzhDQIDAQABAoIBABvVuyF5JsnhU7xv +M09Q9g7cg0SfIAi/0DhiNYxke2Xh1D/ukZilHyLRlND1xs+ebhG0jCf5GO/ziIPe +3mEtWJxqGfvWhOAAUlSKTlBJzc4kKxpsOPj16yzSFhPxmx5ww6XVoqJzEv4a4JwP +FTg78a8R69f8rpXQT8FD2Y49e+2uwVZVJfCjyaLcS2jh0wfaf7YiztSfyeAZNU2z +YIL05wDm6Kw8fsdgZ5tF+tEEx0xBelNh+g4fNVVYdQmUhTM0GHePH5KvLc7LQyxD +z/8ymU5fxikJGFmSS4ncI8ZpmCjV36tkUfZ03n5fW+76Q+gncc+ZKtXRZLgqBdsK +q9ZDTuECgYEAzXMpmOnZh6Mzw6js5WZ2jSw1vuHjEDBOxpKon9UXZD5wZh9bcuxr +ARQy+9/UETppumIW8L+zpmrpZISyriywEkleIjQhDqA9HJGR1lSukhMTyt4bj6ER +f3uyJUzFun5c/QTJEBEJneTFY/Zc4pB+KIdTf3EosVGbtBfkfUXvyyECgYEAxXbA +lg6gmo7ZpGZuPdhMrGiSI8rmGsvIo8Bw7jqdb6E/ksl5nBIxsLcM2lJw8Qe/fvei +g+4Zmc5NOzyOKO1L84ekOC6jfvnGR2jzS2hF/qcNLUEEOKEyzBeniWrAqt80fgeK +cH3zSAXCyLaGJPfdPPqEDYtVBN+zTwNJvHDK5G0CgYEAq1Lcnlpr/vL2iLQGkKno +NINocjw2OFrAZlEIcvik4AA9hLuja+uAs86fUXDujEtUvYtsq+iArEc9R4hs5Ff5 +n9Y0vHsSEftH2tn9bmkBhmiIOcUL4LMlP1TsUrR5srILYycpb891YIjUni5keL6b +pbprw7uefneaSw0dieXXOGECgYBrnmsb3WD+m3hWt1TB9A7lsCBlzYFXfVUemhVy +YRPI8TL6xz+2JdxbGYixvFi9pKFji4dRLAVb5CoHbNt1xs6sLXL9A74rx+mepb5j +jLMJNPZjgZnRW1maDhJLPJlBB2FOhsGWya47xJgCWCgIIea8AzTRROzTOTA6keov +/7E0iQKBgFUWjpHIC0wkBFQFAV1uji3P0Bp6/hCOq9hZNxiaS41AlrhrPDRcIqss +rMrW0Wf0OGDv0+aQXdMkk+nKBjQO3uS6EIj2oDUY/hTFXAKqvDPbHEx3rbtR7NdJ +Sx9/raUX3YoYSNbPwwKcIWiHVnqY/hI8zIb+RFZgwt+mEoLS9/a2 +-----END RSA PRIVATE KEY----- + + diff --git a/lib/docuSign/types.ts b/lib/docuSign/types.ts new file mode 100644 index 00000000..450199ce --- /dev/null +++ b/lib/docuSign/types.ts @@ -0,0 +1,37 @@ +export interface ContractInfo { + tabLabel: string; + value: string; +} + +export interface ContractorInfo { + email: string; + name: string; + roleName: string; +} + +export type poTabLabes = + | "po_no" + | "vendor_name" + | "po_date" + | "project_name" + | "vendor_location" + | "shi_email" + | "vendor_email" + | "po_desc" + | "qty" + | "unit_price" + | "total" + | "grand_total_amount" + | "tax_rate" + | "tax_total" + | "payment_amount" + | "remark"; + +type ContentMap<T extends string> = { + [K in T]: { + tabLabel: K; + value: string; + }; +}; + +export type POContent = ContentMap<poTabLabes>[poTabLabes][]; diff --git a/lib/downloadFile.ts b/lib/downloadFile.ts new file mode 100644 index 00000000..e2777976 --- /dev/null +++ b/lib/downloadFile.ts @@ -0,0 +1,81 @@ +'use server' + +import fs from 'fs/promises' +import path from 'path' +import { NextResponse } from 'next/server' + +/** + * 첨부 파일 다운로드를 위한 서버 액션 + * + * @param filePath 파일의 상대 경로 + * @returns 파일 내용(Base64 인코딩) 및 메타데이터를 포함한 객체 + */ +export async function downloadFileAction(filePath: string) { + try { + // 보안: 파일 경로가 uploads 디렉토리 내에 있는지 확인 + if (!filePath.startsWith('/uploads/') && !filePath.startsWith('uploads/')) { + return { + ok: false, + error: 'Invalid file path. Only files in the uploads directory can be downloaded.' + }; + } + + // 실제 서버 파일 시스템에서의 전체 경로 계산 + // 참고: process.cwd()는 현재 실행 중인 프로세스의 작업 디렉토리를 반환합니다. + // 환경에 따라 public 폴더나 다른 위치를 기준으로 할 수도 있습니다. + const normalizedPath = filePath.startsWith('/') ? filePath.slice(1) : filePath; + const fullPath = path.join(process.cwd(), 'public', normalizedPath); + + // 파일 존재 여부 확인 + try { + await fs.access(fullPath); + } catch { + return { ok: false, error: 'File not found' }; + } + + // 파일 읽기 + const fileBuffer = await fs.readFile(fullPath); + + // 파일 통계 정보 가져오기 + const stats = await fs.stat(fullPath); + + // MIME 타입 추측 + const extension = path.extname(fullPath).toLowerCase(); + let mimeType = 'application/octet-stream'; // 기본값 + + // 일반적인 파일 타입에 대한 MIME 타입 매핑 + const mimeTypes = { + '.pdf': 'application/pdf', + '.doc': 'application/msword', + '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + '.xls': 'application/vnd.ms-excel', + '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.txt': 'text/plain', + }; + + if (extension in mimeTypes) { + mimeType = mimeTypes[extension]; + } + + // Base64로 인코딩하여 반환 + return { + ok: true, + data: { + content: fileBuffer.toString('base64'), + fileName: path.basename(fullPath), + size: stats.size, + mimeType, + }, + }; + } catch (error) { + console.error('Download error:', error); + return { + ok: false, + error: error instanceof Error ? error.message : 'An unknown error occurred' + }; + } +}
\ No newline at end of file diff --git a/lib/equip-class/repository.ts b/lib/equip-class/repository.ts new file mode 100644 index 00000000..ddf98dd2 --- /dev/null +++ b/lib/equip-class/repository.ts @@ -0,0 +1,45 @@ +import db from "@/db/db"; +import { Item, items } from "@/db/schema/items"; +import { tagClasses } from "@/db/schema/vendorData"; +import { + eq, + inArray, + not, + asc, + desc, + and, + ilike, + gte, + lte, + count, + gt, +} from "drizzle-orm"; +import { PgTransaction } from "drizzle-orm/pg-core"; + +export async function selectTagClassLists( + tx: PgTransaction<any, any, any>, + params: { + where?: any; // drizzle-orm의 조건식 (and, eq...) 등 + orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[]; + offset?: number; + limit?: number; + } + ) { + const { where, orderBy, offset = 0, limit = 10 } = params; + + return tx + .select() + .from(tagClasses) + .where(where) + .orderBy(...(orderBy ?? [])) + .offset(offset) + .limit(limit); + } + /** 총 개수 count */ + export async function countTagClassLists( + tx: PgTransaction<any, any, any>, + where?: any + ) { + const res = await tx.select({ count: count() }).from(tagClasses).where(where); + return res[0]?.count ?? 0; + }
\ No newline at end of file diff --git a/lib/equip-class/service.ts b/lib/equip-class/service.ts new file mode 100644 index 00000000..c35f4fbe --- /dev/null +++ b/lib/equip-class/service.ts @@ -0,0 +1,85 @@ +"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택) + +import { revalidateTag, unstable_noStore } from "next/cache"; +import db from "@/db/db"; +import { unstable_cache } from "@/lib/unstable-cache"; +import { filterColumns } from "@/lib/filter-columns"; +import { tagClasses } from "@/db/schema/vendorData"; +import { asc, desc, ilike, inArray, and, gte, lte, not, or } from "drizzle-orm"; +import { GetTagClassesSchema } from "./validation"; +import { countTagClassLists, selectTagClassLists } from "./repository"; + +export async function getTagClassists(input: GetTagClassesSchema) { + + return unstable_cache( + async () => { + try { + const offset = (input.page - 1) * input.perPage; + + // const advancedTable = input.flags.includes("advancedTable"); + const advancedTable = true; + + // advancedTable 모드면 filterColumns()로 where 절 구성 + const advancedWhere = filterColumns({ + table: tagClasses, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + + let globalWhere + if (input.search) { + const s = `%${input.search}%` + globalWhere = or(ilike(tagClasses.code, s), ilike(tagClasses.label, s) + ) + // 필요시 여러 칼럼 OR조건 (status, priority, etc) + } + + const conditions = []; + if (advancedWhere) conditions.push(advancedWhere); + if (globalWhere) conditions.push(globalWhere); + + let finalWhere; + if (conditions.length > 0) { + finalWhere = conditions.length > 1 ? and(...conditions) : conditions[0]; + } + + // 아니면 ilike, inArray, gte 등으로 where 절 구성 + const where = finalWhere + + + const orderBy = + input.sort.length > 0 + ? input.sort.map((item) => + item.desc ? desc(tagClasses[item.id]) : asc(tagClasses[item.id]) + ) + : [asc(tagClasses.createdAt)]; + + // 트랜잭션 내부에서 Repository 호출 + const { data, total } = await db.transaction(async (tx) => { + const data = await selectTagClassLists(tx, { + where, + orderBy, + offset, + limit: input.perPage, + }); + + const total = await countTagClassLists(tx, where); + return { data, total }; + }); + + const pageCount = Math.ceil(total / input.perPage); + + return { data, pageCount }; + } catch (err) { + // 에러 발생 시 디폴트 + return { data: [], pageCount: 0 }; + } + }, + [JSON.stringify(input)], // 캐싱 키 + { + revalidate: 3600, + tags: ["equip-class"], // revalidateTag("items") 호출 시 무효화 + } + )(); + }
\ No newline at end of file diff --git a/lib/equip-class/table/equipClass-table-columns.tsx b/lib/equip-class/table/equipClass-table-columns.tsx new file mode 100644 index 00000000..1255abf3 --- /dev/null +++ b/lib/equip-class/table/equipClass-table-columns.tsx @@ -0,0 +1,99 @@ +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { InfoIcon } from "lucide-react" + +import { formatDate } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" + +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { TagClasses } from "@/db/schema/vendorData" +import { equipclassColumnsConfig } from "@/config/equipClassColumnsConfig" + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<TagClasses> | null>> +} + +/** + * tanstack table 컬럼 정의 (중첩 헤더 버전) + */ +export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<TagClasses>[] { + + + // ---------------------------------------------------------------- + // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 + // ---------------------------------------------------------------- + // 3-1) groupMap: { [groupName]: ColumnDef<TagClasses>[] } + const groupMap: Record<string, ColumnDef<TagClasses>[]> = {} + + equipclassColumnsConfig.forEach((cfg) => { + // 만약 group가 없으면 "_noGroup" 처리 + const groupName = cfg.group || "_noGroup" + + if (!groupMap[groupName]) { + groupMap[groupName] = [] + } + + // child column 정의 + const childCol: ColumnDef<TagClasses> = { + accessorKey: cfg.id, + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title={cfg.label} /> + ), + meta: { + excelHeader: cfg.excelHeader, + group: cfg.group, + type: cfg.type, + }, + cell: ({ row, cell }) => { + + if (cfg.id === "createdAt"||cfg.id === "updatedAt") { + const dateVal = cell.getValue() as Date + return formatDate(dateVal) + } + + return row.getValue(cfg.id) ?? "" + }, + } + + groupMap[groupName].push(childCol) + }) + + // ---------------------------------------------------------------- + // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기 + // ---------------------------------------------------------------- + const nestedColumns: ColumnDef<TagClasses>[] = [] + + // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함 + // 여기서는 그냥 Object.entries 순서 + Object.entries(groupMap).forEach(([groupName, colDefs]) => { + if (groupName === "_noGroup") { + // 그룹 없음 → 그냥 최상위 레벨 컬럼 + nestedColumns.push(...colDefs) + } else { + // 상위 컬럼 + nestedColumns.push({ + id: groupName, + header: groupName, // "Basic Info", "Metadata" 등 + columns: colDefs, + }) + } + }) + + // ---------------------------------------------------------------- + // 4) 최종 컬럼 배열: select, nestedColumns, actions + // ---------------------------------------------------------------- + return [ + ...nestedColumns, + ] +}
\ No newline at end of file diff --git a/lib/equip-class/table/equipClass-table-toolbar-actions.tsx b/lib/equip-class/table/equipClass-table-toolbar-actions.tsx new file mode 100644 index 00000000..5e03d800 --- /dev/null +++ b/lib/equip-class/table/equipClass-table-toolbar-actions.tsx @@ -0,0 +1,53 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Download, RefreshCcw, Upload } from "lucide-react" +import { toast } from "sonner" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" +import { TagClasses } from "@/db/schema/vendorData" + + + +interface ItemsTableToolbarActionsProps { + table: Table<TagClasses> +} + +export function EquipClassTableToolbarActions({ table }: ItemsTableToolbarActionsProps) { + // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식 + const fileInputRef = React.useRef<HTMLInputElement>(null) + + + + return ( + <div className="flex items-center gap-2"> + {/** 4) Export 버튼 */} + <Button + variant="samsung" + size="sm" + className="gap-2" + > + <RefreshCcw className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Get Equip Class</span> + </Button> + + {/** 4) Export 버튼 */} + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(table, { + filename: "tasks", + excludeColumns: ["select", "actions"], + }) + } + className="gap-2" + > + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Export</span> + </Button> + </div> + ) +}
\ No newline at end of file diff --git a/lib/equip-class/table/equipClass-table.tsx b/lib/equip-class/table/equipClass-table.tsx new file mode 100644 index 00000000..56fd42aa --- /dev/null +++ b/lib/equip-class/table/equipClass-table.tsx @@ -0,0 +1,133 @@ +"use client" + +import * as React from "react" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" + +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import { useFeatureFlags } from "./feature-flags-provider" + +import { TagClasses } from "@/db/schema/vendorData" +import { getTagClassists } from "../service" +import { EquipClassTableToolbarActions } from "./equipClass-table-toolbar-actions" +import { getColumns } from "./equipClass-table-columns" + +interface ItemsTableProps { + promises: Promise< + [ + Awaited<ReturnType<typeof getTagClassists>>, + ] + > +} + +export function EquipClassTable({ promises }: ItemsTableProps) { + const { featureFlags } = useFeatureFlags() + + const [{ data, pageCount }] = + React.use(promises) + + +console.log(data) + + const [rowAction, setRowAction] = + React.useState<DataTableRowAction<TagClasses> | null>(null) + + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + /** + * This component can render either a faceted filter or a search filter based on the `options` prop. + * + * @prop options - An array of objects, each representing a filter option. If provided, a faceted filter is rendered. If not, a search filter is rendered. + * + * Each `option` object has the following properties: + * @prop {string} label - The label for the filter option. + * @prop {string} value - The value for the filter option. + * @prop {React.ReactNode} [icon] - An optional icon to display next to the label. + * @prop {boolean} [withCount] - An optional boolean to display the count of the filter option. + */ + const filterFields: DataTableFilterField<TagClasses>[] = [ + + ] + + /** + * Advanced filter fields for the data table. + * These fields provide more complex filtering options compared to the regular filterFields. + * + * Key differences from regular filterFields: + * 1. More field types: Includes 'text', 'multi-select', 'date', and 'boolean'. + * 2. Enhanced flexibility: Allows for more precise and varied filtering options. + * 3. Used with DataTableAdvancedToolbar: Enables a more sophisticated filtering UI. + * 4. Date and boolean types: Adds support for filtering by date ranges and boolean values. + */ + const advancedFilterFields: DataTableAdvancedFilterField<TagClasses>[] = [ + { + id: "code", + label: "Code", + type: "text", + // group: "Basic Info", + }, + { + id: "label", + label: "Label", + type: "text", + // group: "Basic Info", + }, + + + { + id: "createdAt", + label: "Created At", + type: "date", + // group: "Metadata",a + }, + { + id: "updatedAt", + label: "Updated At", + type: "date", + // group: "Metadata", + }, + ] + + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + <DataTable + table={table} + > + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <EquipClassTableToolbarActions table={table} /> + </DataTableAdvancedToolbar> + + </DataTable> + + </> + ) +} diff --git a/lib/equip-class/table/feature-flags-provider.tsx b/lib/equip-class/table/feature-flags-provider.tsx new file mode 100644 index 00000000..81131894 --- /dev/null +++ b/lib/equip-class/table/feature-flags-provider.tsx @@ -0,0 +1,108 @@ +"use client" + +import * as React from "react" +import { useQueryState } from "nuqs" + +import { dataTableConfig, type DataTableConfig } from "@/config/data-table" +import { cn } from "@/lib/utils" +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" + +type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"] + +interface FeatureFlagsContextProps { + featureFlags: FeatureFlagValue[] + setFeatureFlags: (value: FeatureFlagValue[]) => void +} + +const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({ + featureFlags: [], + setFeatureFlags: () => {}, +}) + +export function useFeatureFlags() { + const context = React.useContext(FeatureFlagsContext) + if (!context) { + throw new Error( + "useFeatureFlags must be used within a FeatureFlagsProvider" + ) + } + return context +} + +interface FeatureFlagsProviderProps { + children: React.ReactNode +} + +export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) { + const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>( + "flags", + { + defaultValue: [], + parse: (value) => value.split(",") as FeatureFlagValue[], + serialize: (value) => value.join(","), + eq: (a, b) => + a.length === b.length && a.every((value, index) => value === b[index]), + clearOnDefault: true, + shallow: false, + } + ) + + return ( + <FeatureFlagsContext.Provider + value={{ + featureFlags, + setFeatureFlags: (value) => void setFeatureFlags(value), + }} + > + <div className="w-full overflow-x-auto"> + <ToggleGroup + type="multiple" + variant="outline" + size="sm" + value={featureFlags} + onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)} + className="w-fit gap-0" + > + {dataTableConfig.featureFlags.map((flag, index) => ( + <Tooltip key={flag.value}> + <ToggleGroupItem + value={flag.value} + className={cn( + "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90", + { + "rounded-l-sm border-r-0": index === 0, + "rounded-r-sm": + index === dataTableConfig.featureFlags.length - 1, + } + )} + asChild + > + <TooltipTrigger> + <flag.icon className="size-3.5 shrink-0" aria-hidden="true" /> + {flag.label} + </TooltipTrigger> + </ToggleGroupItem> + <TooltipContent + align="start" + side="bottom" + sideOffset={6} + className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground" + > + <div>{flag.tooltipTitle}</div> + <div className="text-xs text-muted-foreground"> + {flag.tooltipDescription} + </div> + </TooltipContent> + </Tooltip> + ))} + </ToggleGroup> + </div> + {children} + </FeatureFlagsContext.Provider> + ) +} diff --git a/lib/equip-class/validation.ts b/lib/equip-class/validation.ts new file mode 100644 index 00000000..48698ac4 --- /dev/null +++ b/lib/equip-class/validation.ts @@ -0,0 +1,34 @@ +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, +} from "nuqs/server" +import * as z from "zod" + +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" +import { TagClasses } from "@/db/schema/vendorData"; + +export const searchParamsCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault( + [] + ), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser<TagClasses>().withDefault([ + { id: "createdAt", desc: true }, + ]), + code: parseAsString.withDefault(""), + label: parseAsString.withDefault(""), + + // advanced filter + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), + +}) + + + +export type GetTagClassesSchema = Awaited<ReturnType<typeof searchParamsCache.parse>> diff --git a/lib/export.ts b/lib/export.ts new file mode 100644 index 00000000..d910ef6a --- /dev/null +++ b/lib/export.ts @@ -0,0 +1,198 @@ +import { type Table } from "@tanstack/react-table" +import ExcelJS from "exceljs" + +/** + * `exportTableToExcel`: + * - filename: 다운로드할 엑셀 파일 이름(확장자 제외) + * - onlySelected: 선택된 행만 내보낼지 여부 + * - excludeColumns: 제외할 column id들의 배열 (e.g. ["select", "actions"]) + * - useGroupHeader: 그룹화 헤더를 사용할지 여부 (기본 false) + */ +export async function exportTableToExcel<TData>( + table: Table<TData>, + { + filename = "table", + onlySelected = false, + excludeColumns = [], + useGroupHeader = true, + }: { + filename?: string + onlySelected?: boolean + excludeColumns?: string[] + useGroupHeader?: boolean + } = {} +): Promise<void> { + // 1) tanstack에서 실제 사용 중인 leaf columns 가져오기 + const allColumns = table.getAllLeafColumns() + + // 2) excludeColumns 목록에 들어있는 col.id 제거 + const columns = allColumns.filter( + (col) => !excludeColumns.includes(col.id) + ) + + let sheetData: any[][] + + if (useGroupHeader) { + // ────────────── 2줄 헤더 (row1 = 그룹명, row2 = 컬럼헤더) ────────────── + const row1: string[] = [] + const row2: string[] = [] + + columns.forEach((col) => { + // group + const maybeGroup = (col.columnDef.meta as any)?.group + row1.push(maybeGroup ?? "") + + // excelHeader + const maybeExcelHeader = (col.columnDef.meta as any)?.excelHeader + if (typeof maybeExcelHeader === "string") { + row2.push(maybeExcelHeader) + } else { + row2.push(col.id) + } + }) + + // 데이터 + const rowModel = onlySelected + ? table.getFilteredSelectedRowModel() + : table.getRowModel() + + const dataRows = rowModel.rows.map((row) => + columns.map((col) => { + const val = row.getValue(col.id) + if (val == null) return "" + return typeof val === "object" ? JSON.stringify(val) : val + }) + ) + + // 최종 sheetData: [ [그룹들...], [헤더들...], ...데이터들 ] + sheetData = [row1, row2, ...dataRows] + } else { + // ────────────── 기존 1줄 헤더 ────────────── + const headerRow = columns.map((col) => { + const maybeExcelHeader = (col.columnDef.meta as any)?.excelHeader + return typeof maybeExcelHeader === "string" ? maybeExcelHeader : col.id + }) + + // 데이터 + const rowModel = onlySelected + ? table.getFilteredSelectedRowModel() + : table.getRowModel() + + const dataRows = rowModel.rows.map((row) => + columns.map((col) => { + const val = row.getValue(col.id) + if (val == null) return "" + return typeof val === "object" ? JSON.stringify(val) : val + }) + ) + + sheetData = [headerRow, ...dataRows] + } + + // ────────────── ExcelJS 워크북/시트 생성 ────────────── + const workbook = new ExcelJS.Workbook() + const worksheet = workbook.addWorksheet("Sheet1") + + // (추가) 칼럼별 최대 길이 추적 + const maxColumnLengths = columns.map(() => 0) + sheetData.forEach((row) => { + row.forEach((cellValue, colIdx) => { + const cellText = cellValue?.toString() ?? "" + if (cellText.length > maxColumnLengths[colIdx]) { + maxColumnLengths[colIdx] = cellText.length + } + }) + }) + + // 시트에 데이터 추가 + 헤더 스타일 + sheetData.forEach((arr, idx) => { + const row = worksheet.addRow(arr) + + // 헤더 스타일 적용 + if (useGroupHeader) { + // 2줄 헤더 + if (idx < 2) { + row.font = { bold: true } + row.alignment = { horizontal: "center" } + row.eachCell((cell) => { + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFCCCCCC" }, + } + }) + } + } else { + // 1줄 헤더 + if (idx === 0) { + row.font = { bold: true } + row.alignment = { horizontal: "center" } + row.eachCell((cell) => { + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFCCCCCC" }, + } + }) + } + } + }) + + // ────────────── (핵심) 그룹 헤더 병합 로직 ────────────── + if (useGroupHeader) { + // row1 (인덱스 1) = 그룹명 행 + // row2 (인덱스 2) = 실제 컬럼 헤더 행 + const groupRowIndex = 1 + const groupRow = worksheet.getRow(groupRowIndex) + + // 같은 값이 연속되는 열을 병합 + let start = 1 // 시작 열 인덱스 (1-based) + let prevValue = groupRow.getCell(start).value + + for (let c = 2; c <= columns.length; c++) { + const cellVal = groupRow.getCell(c).value + if (cellVal !== prevValue) { + // 이전 그룹명이 빈 문자열이 아니면 병합 + if (prevValue && prevValue.toString().trim() !== "") { + worksheet.mergeCells( + groupRowIndex, + start, + groupRowIndex, + c - 1 + ) + } + // 다음 구간 시작 + start = c + prevValue = cellVal + } + } + + // 마지막 구간까지 병합 + if (prevValue && prevValue.toString().trim() !== "") { + worksheet.mergeCells( + groupRowIndex, + start, + groupRowIndex, + columns.length + ) + } + } + + // ────────────── (추가) 칼럼 너비 자동 조정 ────────────── + maxColumnLengths.forEach((len, idx) => { + // 최소 너비 10, +2 여백 + worksheet.getColumn(idx + 1).width = Math.max(len + 2, 10) + }) + + // ────────────── 최종 파일 다운로드 ────────────── + const buffer = await workbook.xlsx.writeBuffer() + const blob = new Blob([buffer], { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }) + const url = URL.createObjectURL(blob) + const link = document.createElement("a") + link.href = url + link.download = `${filename}.xlsx` + link.click() + URL.revokeObjectURL(url) +}
\ No newline at end of file diff --git a/lib/export_all.ts b/lib/export_all.ts new file mode 100644 index 00000000..6f925fbc --- /dev/null +++ b/lib/export_all.ts @@ -0,0 +1,251 @@ +import { type Table } from "@tanstack/react-table" +import ExcelJS from "exceljs" + +/** + * `exportTableToExcel`: + * - filename: 다운로드할 엑셀 파일 이름(확장자 제외) + * - onlySelected: 선택된 행만 내보낼지 여부 + * - excludeColumns: 제외할 column id들의 배열 (e.g. ["select", "actions"]) + * - useGroupHeader: 그룹화 헤더를 사용할지 여부 (기본 false) + * - allPages: true일 경우, 페이징 상관없이 모든 행을 내보냄 + * + * 추가: + * - i18n: (key: string) => string | undefined + * => excelHeader나 group 값이 'myKey'처럼 i18n 키라면 이 함수를 통해 번역 문자열 반환 + * - customHeaders: { [colId: string]: string } + * => 특정 col.id에 대해 강제로 헤더를 지정하고 싶을 때 사용 + */ +export async function exportTableToExcel<TData>( + table: Table<TData>, + { + filename = "table", + onlySelected = false, + excludeColumns = [], + useGroupHeader = true, + allPages = false, + /** 아래 2개가 새로 추가된 옵션 */ + i18n, + customHeaders = {}, + }: { + filename?: string + onlySelected?: boolean + excludeColumns?: string[] + useGroupHeader?: boolean + allPages?: boolean + /** excelHeader나 group 값이 i18n 키일 경우, 해당 함수를 통해 번역 */ + i18n?: (key: string) => string + /** 특정 col.id에 대한 강제 헤더 지정 */ + customHeaders?: Record<string, string> + } = {} +): Promise<void> { + // 1) tanstack에서 실제 사용 중인 leaf columns 가져오기 + const allColumns = table.getAllLeafColumns() + + // 2) excludeColumns 목록에 들어있는 col.id 제거 + const columns = allColumns.filter( + (col) => !excludeColumns.includes(col.id) + ) + + // 실제로 기록할 sheetData(배열 형식) + let sheetData: any[][] + + // ────────────── 2줄 헤더 (group + excelHeader) vs 1줄 헤더 ────────────── + if (useGroupHeader) { + // 2줄 헤더 (row1 = 그룹명, row2 = 컬럼헤더) + const row1: string[] = [] + const row2: string[] = [] + + columns.forEach((col) => { + const meta = col.columnDef.meta as any + // 1) group (그룹헤더) + const groupKey = meta?.group + let groupLabel = groupKey ?? "" + if (groupLabel && i18n) { + // groupKey가 i18n 키라면 번역 적용 + const maybeTranslated = i18n(groupLabel) + if (maybeTranslated) { + groupLabel = maybeTranslated + } + } + row1.push(groupLabel) + + // 2) excelHeader (실제 컬럼 헤더) + // (a) customHeaders[col.id]가 우선 + if (customHeaders[col.id]) { + row2.push(customHeaders[col.id]) + } else { + // (b) meta?.excelHeader가 있으면 그것을 사용 + const maybeExcelHeader = meta?.excelHeader + if (typeof maybeExcelHeader === "string") { + // i18n 함수가 있다면 i18n 키로 가정하고 번역 시도 + if (i18n) { + const maybeTranslated = i18n(maybeExcelHeader) + row2.push(maybeTranslated || maybeExcelHeader) + } else { + row2.push(maybeExcelHeader) + } + } else { + // 모두 없으면 col.id 사용 + row2.push(col.id) + } + } + }) + + // ───────────────────────────────────────────────── + // 필요한 데이터 행 추출 + const rowModel = onlySelected + ? table.getFilteredSelectedRowModel() + : allPages + ? table.getPrePaginationRowModel() + : table.getRowModel() + + const dataRows = rowModel.rows.map((row) => + columns.map((col) => { + const val = row.getValue(col.id) + if (val == null) return "" + return typeof val === "object" ? JSON.stringify(val) : val + }) + ) + + // 최종 sheetData: [ [그룹들...], [헤더들...], ...데이터들 ] + sheetData = [row1, row2, ...dataRows] + + } else { + // ────────────── 기존 1줄 헤더 ────────────── + const headerRow = columns.map((col) => { + const meta = col.columnDef.meta as any + + // 1) customHeaders[col.id]가 우선 + if (customHeaders[col.id]) { + return customHeaders[col.id] + } + + // 2) meta?.excelHeader가 문자열이면 + if (typeof meta?.excelHeader === "string") { + if (i18n) { + const maybeTranslated = i18n(meta.excelHeader) + return maybeTranslated || meta.excelHeader + } else { + return meta.excelHeader + } + } + + // 3) 모든 것이 없으면 col.id + return col.id + }) + + // 데이터 + const rowModel = onlySelected + ? table.getFilteredSelectedRowModel() + : allPages + ? table.getPrePaginationRowModel() + : table.getRowModel() + + const dataRows = rowModel.rows.map((row) => + columns.map((col) => { + const val = row.getValue(col.id) + if (val == null) return "" + return typeof val === "object" ? JSON.stringify(val) : val + }) + ) + + sheetData = [headerRow, ...dataRows] + } + + // ────────────── ExcelJS 워크북/시트 생성 ────────────── + const workbook = new ExcelJS.Workbook() + const worksheet = workbook.addWorksheet("Sheet1") + + // (추가) 칼럼별 최대 길이 추적 + const maxColumnLengths = columns.map(() => 0) + sheetData.forEach((row) => { + row.forEach((cellValue, colIdx) => { + const cellText = cellValue?.toString() ?? "" + if (cellText.length > maxColumnLengths[colIdx]) { + maxColumnLengths[colIdx] = cellText.length + } + }) + }) + + // 시트에 데이터 추가 + 헤더 스타일 + sheetData.forEach((arr, idx) => { + const row = worksheet.addRow(arr) + + // 헤더 스타일 적용 + if (useGroupHeader) { + // 2줄 헤더 + if (idx < 2) { + row.font = { bold: true } + row.alignment = { horizontal: "center" } + row.eachCell((cell) => { + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFCCCCCC" }, + } + }) + } + } else { + // 1줄 헤더 + if (idx === 0) { + row.font = { bold: true } + row.alignment = { horizontal: "center" } + row.eachCell((cell) => { + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFCCCCCC" }, + } + }) + } + } + }) + + // ────────────── (핵심) 그룹 헤더 병합 로직 ────────────── + if (useGroupHeader) { + // row1 (인덱스 1) = 그룹명 행 + // row2 (인덱스 2) = 실제 컬럼 헤더 행 + const groupRowIndex = 1 + const groupRow = worksheet.getRow(groupRowIndex) + + // 같은 값이 연속되는 열을 병합 + let start = 1 // 시작 열 인덱스 (1-based) + let prevValue = groupRow.getCell(start).value + + for (let c = 2; c <= columns.length; c++) { + const cellVal = groupRow.getCell(c).value + if (cellVal !== prevValue) { + // 이전 그룹명이 빈 문자열이 아니면 병합 + if (prevValue && prevValue.toString().trim() !== "") { + worksheet.mergeCells(groupRowIndex, start, groupRowIndex, c - 1) + } + // 다음 구간 시작 + start = c + prevValue = cellVal + } + } + + // 마지막 구간까지 병합 + if (prevValue && prevValue.toString().trim() !== "") { + worksheet.mergeCells(groupRowIndex, start, groupRowIndex, columns.length) + } + } + + // ────────────── (추가) 칼럼 너비 자동 조정 ────────────── + maxColumnLengths.forEach((len, idx) => { + // 최소 너비 10, +2 여백 + worksheet.getColumn(idx + 1).width = Math.max(len + 2, 10) + }) + + // ────────────── 최종 파일 다운로드 ────────────── + const buffer = await workbook.xlsx.writeBuffer() + const blob = new Blob([buffer], { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }) + const url = URL.createObjectURL(blob) + const link = document.createElement("a") + link.href = url + link.download = `${filename}.xlsx` + link.click() + URL.revokeObjectURL(url) +}
\ No newline at end of file diff --git a/lib/filter-columns.ts b/lib/filter-columns.ts new file mode 100644 index 00000000..4b995925 --- /dev/null +++ b/lib/filter-columns.ts @@ -0,0 +1,193 @@ +import { isEmpty, isNotEmpty } from "@/db/utils" +import type { Filter, JoinOperator } from "@/types/table" +import { addDays, endOfDay, startOfDay } from "date-fns" +import { + and, + eq, + gt, + gte, + ilike, + inArray, + lt, + lte, + ne, + notIlike, + notInArray, + or, + type AnyColumn, + type SQL, + type Table, +} from "drizzle-orm" +import type { PgTable, PgView } from "drizzle-orm/pg-core" + +type TableOrView = PgTable | PgView<any> + +/** + * Construct SQL conditions based on the provided filters for a specific table. + * + * This function takes a table and an array of filters, and returns a SQL + * expression that represents the logical combination of these conditions. The conditions + * are combined using the specified join operator (either 'AND' or 'OR'), which is determined + * by the first filter's joinOperator property. + * + * Each filter can specify various operators (e.g., equality, inequality, + * comparison for numbers and dates, etc.) and the function will generate the appropriate + * SQL expressions based on the filter's type and value. + * + * @param table - The table to apply the filters on. + * @param filters - An array of filters to be applied to the table. + * @param joinOperator - The join operator to use for combining the filters. + * @returns A SQL expression representing the combined filters, or undefined if no valid + * filters are found. + */ + +export function filterColumns<T extends TableOrView>({ + table, + filters, + joinOperator, +}: { + table: T + filters: Filter<T>[] + joinOperator: JoinOperator +}): SQL | undefined { + + const joinFn = joinOperator === "and" ? and : or + + const conditions = filters.map((filter) => { + const column = getColumn(table, filter.id) + + switch (filter.operator) { + case "eq": + if (Array.isArray(filter.value)) { + return inArray(column, filter.value) + } else if ( + column.dataType === "boolean" && + typeof filter.value === "string" + ) { + return eq(column, filter.value === "true") + } else if (filter.type === "date") { + const date = new Date(filter.value) + const start = startOfDay(date) + const end = endOfDay(date) + return and(gte(column, start), lte(column, end)) + } else { + return eq(column, filter.value) + } + case "ne": + if (Array.isArray(filter.value)) { + return notInArray(column, filter.value) + } else if (column.dataType === "boolean") { + return ne(column, filter.value === "true") + } else if (filter.type === "date") { + const date = new Date(filter.value) + const start = startOfDay(date) + const end = endOfDay(date) + return or(lt(column, start), gt(column, end)) + } else { + return ne(column, filter.value) + } + case "iLike": + return filter.type === "text" && typeof filter.value === "string" + ? ilike(column, `%${filter.value}%`) + : undefined + case "notILike": + return filter.type === "text" && typeof filter.value === "string" + ? notIlike(column, `%${filter.value}%`) + : undefined + case "lt": + return filter.type === "number" + ? lt(column, filter.value) + : filter.type === "date" && typeof filter.value === "string" + ? lt(column, endOfDay(new Date(filter.value))) + : undefined + case "lte": + return filter.type === "number" + ? lte(column, filter.value) + : filter.type === "date" && typeof filter.value === "string" + ? lte(column, endOfDay(new Date(filter.value))) + : undefined + case "gt": + return filter.type === "number" + ? gt(column, filter.value) + : filter.type === "date" && typeof filter.value === "string" + ? gt(column, startOfDay(new Date(filter.value))) + : undefined + case "gte": + return filter.type === "number" + ? gte(column, filter.value) + : filter.type === "date" && typeof filter.value === "string" + ? gte(column, startOfDay(new Date(filter.value))) + : undefined + case "isBetween": + return filter.type === "date" && + Array.isArray(filter.value) && + filter.value.length === 2 + ? and( + filter.value[0] + ? gte(column, startOfDay(new Date(filter.value[0]))) + : undefined, + filter.value[1] + ? lte(column, endOfDay(new Date(filter.value[1]))) + : undefined + ) + : undefined + case "isRelativeToToday": + if (filter.type === "date" && typeof filter.value === "string") { + const today = new Date() + const [amount, unit] = filter.value.split(" ") ?? [] + let startDate: Date + let endDate: Date + + if (!amount || !unit) return undefined + + switch (unit) { + case "days": + startDate = startOfDay(addDays(today, parseInt(amount))) + endDate = endOfDay(startDate) + break + case "weeks": + startDate = startOfDay(addDays(today, parseInt(amount) * 7)) + endDate = endOfDay(addDays(startDate, 6)) + break + case "months": + startDate = startOfDay(addDays(today, parseInt(amount) * 30)) + endDate = endOfDay(addDays(startDate, 29)) + break + default: + return undefined + } + + return and(gte(column, startDate), lte(column, endDate)) + } + return undefined + case "isEmpty": + return isEmpty(column) + case "isNotEmpty": + return isNotEmpty(column) + + default: + throw new Error(`Unsupported operator: ${filter.operator}`) + } + }) + + const validConditions = conditions.filter( + (condition) => condition !== undefined + ) + + + return validConditions.length > 0 ? joinFn(...validConditions) : undefined +} + +/** + * Get table column. + * @param table The table to get the column from. + * @param columnKey The key of the column to retrieve from the table. + * @returns The column corresponding to the provided key. + */ + +export function getColumn<T extends TableOrView>( + table: T, + columnKey: keyof T +): AnyColumn { + return table[columnKey] as AnyColumn +}
\ No newline at end of file diff --git a/lib/fonts.ts b/lib/fonts.ts new file mode 100644 index 00000000..c5e8958d --- /dev/null +++ b/lib/fonts.ts @@ -0,0 +1,5 @@ +import { GeistMono } from "geist/font/mono" +import { GeistSans } from "geist/font/sans" + +export const fontSans = GeistSans +export const fontMono = GeistMono diff --git a/lib/form-list/repository.ts b/lib/form-list/repository.ts new file mode 100644 index 00000000..ced320db --- /dev/null +++ b/lib/form-list/repository.ts @@ -0,0 +1,46 @@ +import db from "@/db/db"; +import { Item, items } from "@/db/schema/items"; +import { tagTypeClassFormMappings } from "@/db/schema/vendorData"; +import { + eq, + inArray, + not, + asc, + desc, + and, + ilike, + gte, + lte, + count, + gt, +} from "drizzle-orm"; +import { PgTransaction } from "drizzle-orm/pg-core"; + +export async function selectFormLists( + tx: PgTransaction<any, any, any>, + params: { + where?: any; // drizzle-orm의 조건식 (and, eq...) 등 + orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[]; + offset?: number; + limit?: number; + } + ) { + const { where, orderBy, offset = 0, limit = 10 } = params; + + return tx + .select() + .from(tagTypeClassFormMappings) + .where(where) + .orderBy(...(orderBy ?? [])) + .offset(offset) + .limit(limit); + } + /** 총 개수 count */ + export async function countFormLists( + tx: PgTransaction<any, any, any>, + where?: any + ) { + const res = await tx.select({ count: count() }).from(tagTypeClassFormMappings).where(where); + return res[0]?.count ?? 0; + } +
\ No newline at end of file diff --git a/lib/form-list/service.ts b/lib/form-list/service.ts new file mode 100644 index 00000000..64156cf4 --- /dev/null +++ b/lib/form-list/service.ts @@ -0,0 +1,84 @@ +"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택) + +import { revalidateTag, unstable_noStore } from "next/cache"; +import db from "@/db/db"; +import { unstable_cache } from "@/lib/unstable-cache"; +import { GetFormListsSchema } from "./validation"; +import { filterColumns } from "@/lib/filter-columns"; +import { tagTypeClassFormMappings } from "@/db/schema/vendorData"; +import { asc, desc, ilike, inArray, and, gte, lte, not, or } from "drizzle-orm"; +import { countFormLists, selectFormLists } from "./repository"; + +export async function getFormLists(input: GetFormListsSchema) { + + return unstable_cache( + async () => { + try { + const offset = (input.page - 1) * input.perPage; + + // const advancedTable = input.flags.includes("advancedTable"); + const advancedTable = true; + + // advancedTable 모드면 filterColumns()로 where 절 구성 + const advancedWhere = filterColumns({ + table: tagTypeClassFormMappings, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + + let globalWhere + if (input.search) { + const s = `%${input.search}%` + globalWhere = or(ilike(tagTypeClassFormMappings.formCode, s), ilike(tagTypeClassFormMappings.formName, s) + , ilike(tagTypeClassFormMappings.tagTypeLabel, s) , ilike(tagTypeClassFormMappings.classLabel, s) + ) + // 필요시 여러 칼럼 OR조건 (status, priority, etc) + } + + const finalWhere = and( + // advancedWhere or your existing conditions + advancedWhere, + globalWhere // and()함수로 결합 or or() 등으로 결합 + ) + + + // 아니면 ilike, inArray, gte 등으로 where 절 구성 + const where = finalWhere + + + const orderBy = + input.sort.length > 0 + ? input.sort.map((item) => + item.desc ? desc(tagTypeClassFormMappings[item.id]) : asc(tagTypeClassFormMappings[item.id]) + ) + : [asc(tagTypeClassFormMappings.createdAt)]; + + // 트랜잭션 내부에서 Repository 호출 + const { data, total } = await db.transaction(async (tx) => { + const data = await selectFormLists(tx, { + where, + orderBy, + offset, + limit: input.perPage, + }); + + const total = await countFormLists(tx, where); + return { data, total }; + }); + + const pageCount = Math.ceil(total / input.perPage); + + return { data, pageCount }; + } catch (err) { + // 에러 발생 시 디폴트 + return { data: [], pageCount: 0 }; + } + }, + [JSON.stringify(input)], // 캐싱 키 + { + revalidate: 3600, + tags: ["form-lists"], // revalidateTag("items") 호출 시 무효화 + } + )(); + }
\ No newline at end of file diff --git a/lib/form-list/table/feature-flags-provider.tsx b/lib/form-list/table/feature-flags-provider.tsx new file mode 100644 index 00000000..81131894 --- /dev/null +++ b/lib/form-list/table/feature-flags-provider.tsx @@ -0,0 +1,108 @@ +"use client" + +import * as React from "react" +import { useQueryState } from "nuqs" + +import { dataTableConfig, type DataTableConfig } from "@/config/data-table" +import { cn } from "@/lib/utils" +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" + +type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"] + +interface FeatureFlagsContextProps { + featureFlags: FeatureFlagValue[] + setFeatureFlags: (value: FeatureFlagValue[]) => void +} + +const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({ + featureFlags: [], + setFeatureFlags: () => {}, +}) + +export function useFeatureFlags() { + const context = React.useContext(FeatureFlagsContext) + if (!context) { + throw new Error( + "useFeatureFlags must be used within a FeatureFlagsProvider" + ) + } + return context +} + +interface FeatureFlagsProviderProps { + children: React.ReactNode +} + +export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) { + const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>( + "flags", + { + defaultValue: [], + parse: (value) => value.split(",") as FeatureFlagValue[], + serialize: (value) => value.join(","), + eq: (a, b) => + a.length === b.length && a.every((value, index) => value === b[index]), + clearOnDefault: true, + shallow: false, + } + ) + + return ( + <FeatureFlagsContext.Provider + value={{ + featureFlags, + setFeatureFlags: (value) => void setFeatureFlags(value), + }} + > + <div className="w-full overflow-x-auto"> + <ToggleGroup + type="multiple" + variant="outline" + size="sm" + value={featureFlags} + onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)} + className="w-fit gap-0" + > + {dataTableConfig.featureFlags.map((flag, index) => ( + <Tooltip key={flag.value}> + <ToggleGroupItem + value={flag.value} + className={cn( + "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90", + { + "rounded-l-sm border-r-0": index === 0, + "rounded-r-sm": + index === dataTableConfig.featureFlags.length - 1, + } + )} + asChild + > + <TooltipTrigger> + <flag.icon className="size-3.5 shrink-0" aria-hidden="true" /> + {flag.label} + </TooltipTrigger> + </ToggleGroupItem> + <TooltipContent + align="start" + side="bottom" + sideOffset={6} + className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground" + > + <div>{flag.tooltipTitle}</div> + <div className="text-xs text-muted-foreground"> + {flag.tooltipDescription} + </div> + </TooltipContent> + </Tooltip> + ))} + </ToggleGroup> + </div> + {children} + </FeatureFlagsContext.Provider> + ) +} diff --git a/lib/form-list/table/formLists-table-columns.tsx b/lib/form-list/table/formLists-table-columns.tsx new file mode 100644 index 00000000..f638c4df --- /dev/null +++ b/lib/form-list/table/formLists-table-columns.tsx @@ -0,0 +1,132 @@ +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { InfoIcon } from "lucide-react" + +import { formatDate } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" + +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { formListsColumnsConfig } from "@/config/formListsColumnsConfig" +import { TagTypeClassFormMappings } from "@/db/schema/vendorData" + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<TagTypeClassFormMappings> | null>> +} + +/** + * tanstack table 컬럼 정의 (중첩 헤더 버전) + */ +export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<TagTypeClassFormMappings>[] { + // ---------------------------------------------------------------- + // 1) select 컬럼 (체크박스) + // ---------------------------------------------------------------- + + + // ---------------------------------------------------------------- + // 2) actions 컬럼 (단일 버튼 - Meta Info 바로 보기) + // ---------------------------------------------------------------- + const actionsColumn: ColumnDef<TagTypeClassFormMappings> = { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + return ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="icon" + onClick={() => setRowAction({ row, type: "items" })} + > + <InfoIcon className="h-4 w-4" aria-hidden="true" /> + </Button> + </TooltipTrigger> + <TooltipContent> + View Meta Info + </TooltipContent> + </Tooltip> + </TooltipProvider> + ) + }, + size: 40, + } + + // ---------------------------------------------------------------- + // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 + // ---------------------------------------------------------------- + // 3-1) groupMap: { [groupName]: ColumnDef<TagTypeClassFormMappings>[] } + const groupMap: Record<string, ColumnDef<TagTypeClassFormMappings>[]> = {} + + formListsColumnsConfig.forEach((cfg) => { + // 만약 group가 없으면 "_noGroup" 처리 + const groupName = cfg.group || "_noGroup" + + if (!groupMap[groupName]) { + groupMap[groupName] = [] + } + + // child column 정의 + const childCol: ColumnDef<TagTypeClassFormMappings> = { + accessorKey: cfg.id, + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title={cfg.label} /> + ), + meta: { + excelHeader: cfg.excelHeader, + group: cfg.group, + type: cfg.type, + }, + cell: ({ row, cell }) => { + + if (cfg.id === "createdAt"||cfg.id === "updatedAt") { + const dateVal = cell.getValue() as Date + return formatDate(dateVal) + } + + return row.getValue(cfg.id) ?? "" + }, + } + + groupMap[groupName].push(childCol) + }) + + // ---------------------------------------------------------------- + // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기 + // ---------------------------------------------------------------- + const nestedColumns: ColumnDef<TagTypeClassFormMappings>[] = [] + + // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함 + // 여기서는 그냥 Object.entries 순서 + Object.entries(groupMap).forEach(([groupName, colDefs]) => { + if (groupName === "_noGroup") { + // 그룹 없음 → 그냥 최상위 레벨 컬럼 + nestedColumns.push(...colDefs) + } else { + // 상위 컬럼 + nestedColumns.push({ + id: groupName, + header: groupName, // "Basic Info", "Metadata" 등 + columns: colDefs, + }) + } + }) + + // ---------------------------------------------------------------- + // 4) 최종 컬럼 배열: select, nestedColumns, actions + // ---------------------------------------------------------------- + return [ + ...nestedColumns, + actionsColumn, + ] +}
\ No newline at end of file diff --git a/lib/form-list/table/formLists-table-toolbar-actions.tsx b/lib/form-list/table/formLists-table-toolbar-actions.tsx new file mode 100644 index 00000000..346a3980 --- /dev/null +++ b/lib/form-list/table/formLists-table-toolbar-actions.tsx @@ -0,0 +1,53 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Download, RefreshCcw, Upload } from "lucide-react" +import { toast } from "sonner" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" +import { TagTypeClassFormMappings } from "@/db/schema/vendorData" + + + +interface ItemsTableToolbarActionsProps { + table: Table<TagTypeClassFormMappings> +} + +export function FormListsTableToolbarActions({ table }: ItemsTableToolbarActionsProps) { + // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식 + const fileInputRef = React.useRef<HTMLInputElement>(null) + + + + return ( + <div className="flex items-center gap-2"> + {/** 4) Export 버튼 */} + <Button + variant="samsung" + size="sm" + className="gap-2" + > + <RefreshCcw className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Get Forms</span> + </Button> + + {/** 4) Export 버튼 */} + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(table, { + filename: "tasks", + excludeColumns: ["select", "actions"], + }) + } + className="gap-2" + > + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Export</span> + </Button> + </div> + ) +}
\ No newline at end of file diff --git a/lib/form-list/table/formLists-table.tsx b/lib/form-list/table/formLists-table.tsx new file mode 100644 index 00000000..be252655 --- /dev/null +++ b/lib/form-list/table/formLists-table.tsx @@ -0,0 +1,151 @@ +"use client" + +import * as React from "react" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" + +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import { useFeatureFlags } from "./feature-flags-provider" + +import { TagTypeClassFormMappings } from "@/db/schema/vendorData" +import { getFormLists } from "../service" +import { getColumns } from "./formLists-table-columns" +import { FormListsTableToolbarActions } from "./formLists-table-toolbar-actions" +import { ViewMetas } from "./meta-sheet" + +interface ItemsTableProps { + promises: Promise< + [ + Awaited<ReturnType<typeof getFormLists>>, + ] + > +} + +export function FormListsTable({ promises }: ItemsTableProps) { + const { featureFlags } = useFeatureFlags() + + const [{ data, pageCount }] = + React.use(promises) + + + const [rowAction, setRowAction] = + React.useState<DataTableRowAction<TagTypeClassFormMappings> | null>(null) + + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + /** + * This component can render either a faceted filter or a search filter based on the `options` prop. + * + * @prop options - An array of objects, each representing a filter option. If provided, a faceted filter is rendered. If not, a search filter is rendered. + * + * Each `option` object has the following properties: + * @prop {string} label - The label for the filter option. + * @prop {string} value - The value for the filter option. + * @prop {React.ReactNode} [icon] - An optional icon to display next to the label. + * @prop {boolean} [withCount] - An optional boolean to display the count of the filter option. + */ + const filterFields: DataTableFilterField<TagTypeClassFormMappings>[] = [ + + + ] + + /** + * Advanced filter fields for the data table. + * These fields provide more complex filtering options compared to the regular filterFields. + * + * Key differences from regular filterFields: + * 1. More field types: Includes 'text', 'multi-select', 'date', and 'boolean'. + * 2. Enhanced flexibility: Allows for more precise and varied filtering options. + * 3. Used with DataTableAdvancedToolbar: Enables a more sophisticated filtering UI. + * 4. Date and boolean types: Adds support for filtering by date ranges and boolean values. + */ + const advancedFilterFields: DataTableAdvancedFilterField<TagTypeClassFormMappings>[] = [ + { + id: "formCode", + label: "Form Code", + type: "text", + + }, + { + id: "formName", + label: "Form Name", + type: "text", + + }, + { + id: "tagTypeLabel", + label: "Tag Type", + type: "text", + + }, + { + id: "classLabel", + label: "Class", + type: "text", + + }, + + { + id: "createdAt", + label: "Created At", + type: "date", + + }, + { + id: "updatedAt", + label: "Updated At", + type: "date", + + }, + ] + + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + <DataTable + table={table} + + > + + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <FormListsTableToolbarActions table={table} /> + </DataTableAdvancedToolbar> + + </DataTable> + <ViewMetas + open={rowAction?.type === "items"} + onOpenChange={() => setRowAction(null)} + form={rowAction?.row.original ?? null} + /> + + </> + ) +} diff --git a/lib/form-list/table/meta-sheet.tsx b/lib/form-list/table/meta-sheet.tsx new file mode 100644 index 00000000..155e4f5a --- /dev/null +++ b/lib/form-list/table/meta-sheet.tsx @@ -0,0 +1,245 @@ +"use client" + +import * as React from "react" +import { useMemo } from "react" +import { Badge } from "@/components/ui/badge" +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle +} from "@/components/ui/sheet" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from "@/components/ui/table" +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger +} from "@/components/ui/tabs" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle +} from "@/components/ui/card" +import type { TagTypeClassFormMappings } from "@/db/schema/vendorData" // or your actual type +import { fetchFormMetadata, FormColumn } from "@/lib/forms/services" + + +interface ViewMetasProps { + open: boolean + onOpenChange: (open: boolean) => void + form: TagTypeClassFormMappings | null +} + +export function ViewMetas({ open, onOpenChange, form }: ViewMetasProps) { + // metadata & loading + const [metadata, setMetadata] = React.useState<{ + formName: string + formCode: string + columns: FormColumn[] + } | null>(null) + const [loading, setLoading] = React.useState(false) + + // Group columns by type for better organization + const groupedColumns = useMemo(() => { + if (!metadata?.columns) return {} + + return metadata.columns.reduce((acc, column) => { + const type = column.type + if (!acc[type]) { + acc[type] = [] + } + acc[type].push(column) + return acc + }, {} as Record<string, FormColumn[]>) + }, [metadata]) + + // Types for the tabs + const columnTypes = useMemo(() => { + return Object.keys(groupedColumns) + }, [groupedColumns]) + + // Fetch metadata when form changes and dialog is opened + React.useEffect(() => { + async function fetchMeta() { + if (!form || !open) return + + setLoading(true) + try { + // 서버 액션 호출 + const metaData = await fetchFormMetadata(form.formCode) + if (metaData) { + setMetadata(metaData) + } else { + setMetadata(null) + } + } catch (error) { + console.error("Error fetching form metadata:", error) + setMetadata(null) + } finally { + setLoading(false) + } + } + + fetchMeta() + }, [form, open]) + + if (!form) return null + + return ( + <Sheet open={open} onOpenChange={onOpenChange}> + <SheetContent className="sm:max-w-xl md:max-w-3xl lg:max-w-4xl xl:max-w-5xl overflow-y-auto"> + + <SheetHeader className="mb-4"> + <SheetTitle>Form Metadata</SheetTitle> + <SheetDescription> + </SheetDescription> + {loading ? ( + <div className="text-muted-foreground">Loading metadata...</div> + ) : metadata ? ( + <div className="flex flex-col gap-1"> + <div className="flex gap-2 items-center"> + <span className="font-semibold">Form Code:</span> + <Badge variant="outline">{metadata.formCode}</Badge> + </div> + <div className="flex gap-2 items-center"> + <span className="font-semibold">Form Name:</span> + <span>{metadata.formName}</span> + </div> + </div> + ) : ( + <div className="text-sm text-muted-foreground"> + No metadata found for form code: {form.formCode} + </div> + )} + + </SheetHeader> + + {loading ? ( + <div className="flex items-center justify-center h-40"> + <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div> + </div> + ) : metadata ? ( + <Tabs defaultValue="all" className="mt-4"> + <TabsList className="mb-4 flex-wrap"> + <TabsTrigger value="all">All ({metadata.columns.length})</TabsTrigger> + {columnTypes.map((type) => ( + <TabsTrigger key={type} value={type}> + {type} ({groupedColumns[type].length}) + </TabsTrigger> + ))} + </TabsList> + + <TabsContent value="all"> + <Card> + <CardHeader> + <CardTitle>All Fields</CardTitle> + <CardDescription>All form fields and their properties</CardDescription> + </CardHeader> + <CardContent> + <Table> + <TableHeader> + <TableRow> + <TableHead>Key</TableHead> + <TableHead>Label</TableHead> + <TableHead>Type</TableHead> + <TableHead>Options</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {metadata.columns.map((column) => ( + <TableRow key={column.key}> + <TableCell className="font-mono text-sm">{column.key}</TableCell> + <TableCell>{column.label}</TableCell> + <TableCell> + <Badge variant="secondary">{column.type}</Badge> + </TableCell> + <TableCell> + {column.options ? ( + <div className="flex flex-wrap gap-1"> + {column.options.map((option) => ( + <Badge key={option} variant="outline" className="text-xs"> + {option} + </Badge> + ))} + </div> + ) : ( + "-" + )} + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </CardContent> + </Card> + </TabsContent> + + {columnTypes.map((type) => ( + <TabsContent key={type} value={type}> + <Card> + <CardHeader> + <CardTitle>{type.charAt(0).toUpperCase() + type.slice(1)} Fields</CardTitle> + <CardDescription>Fields with type "{type}"</CardDescription> + </CardHeader> + <CardContent> + <Table> + <TableHeader> + <TableRow> + <TableHead>Key</TableHead> + <TableHead>Label</TableHead> + {type === "select" && <TableHead>Options</TableHead>} + </TableRow> + </TableHeader> + <TableBody> + {groupedColumns[type].map((column) => ( + <TableRow key={column.key}> + <TableCell className="font-mono text-sm">{column.key}</TableCell> + <TableCell>{column.label}</TableCell> + {type === "select" && ( + <TableCell> + {column.options ? ( + <div className="flex flex-wrap gap-1"> + {column.options.map((option) => ( + <Badge key={option} variant="outline" className="text-xs"> + {option} + </Badge> + ))} + </div> + ) : ( + "-" + )} + </TableCell> + )} + </TableRow> + ))} + </TableBody> + </Table> + </CardContent> + </Card> + </TabsContent> + ))} + </Tabs> + ) : ( + <div className="text-center py-8"> + <div className="text-lg font-medium">No metadata found</div> + <p className="text-muted-foreground mt-2"> + Could not find metadata for form code: {form.formCode} + </p> + </div> + )} + + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/lib/form-list/validation.ts b/lib/form-list/validation.ts new file mode 100644 index 00000000..c8baf960 --- /dev/null +++ b/lib/form-list/validation.ts @@ -0,0 +1,36 @@ +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, +} from "nuqs/server" +import * as z from "zod" + +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" +import { TagTypeClassFormMappings } from "@/db/schema/vendorData"; + +export const searchParamsCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault( + [] + ), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser<TagTypeClassFormMappings>().withDefault([ + { id: "createdAt", desc: true }, + ]), + tagTypeLabel: parseAsString.withDefault(""), + classLabel: parseAsString.withDefault(""), + formCode: parseAsString.withDefault(""), + formName: parseAsString.withDefault(""), + + // advanced filter + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), + +}) + + + +export type GetFormListsSchema = Awaited<ReturnType<typeof searchParamsCache.parse>> diff --git a/lib/forms/services.ts b/lib/forms/services.ts new file mode 100644 index 00000000..e5fc8666 --- /dev/null +++ b/lib/forms/services.ts @@ -0,0 +1,645 @@ +// lib/forms/services.ts +"use server" + +import db from "@/db/db"; +import { formEntries, formMetas, forms, tags, tagTypeClassFormMappings } from "@/db/schema/vendorData" +import { eq, and, desc, sql, DrizzleError, or } from "drizzle-orm" +import { unstable_cache } from "next/cache" +import { revalidateTag } from "next/cache" +import { getErrorMessage } from "../handle-error"; +import { DataTableColumnJSON } from "@/components/form-data/form-data-table-columns"; + +export interface FormInfo { + id: number + formCode: string + formName: string + // tagType: string +} + +export async function getFormsByContractItemId(contractItemId: number | null) { + // 유효성 검사 + if (!contractItemId || contractItemId <= 0) { + console.warn(`Invalid contractItemId: ${contractItemId}`); + return { forms: [] }; + } + + // 고유 캐시 키 + const cacheKey = `forms-${contractItemId}`; + + try { + return unstable_cache( + async () => { + console.log(`[Forms Service] Fetching forms for contractItemId: ${contractItemId}`); + + try { + // 데이터베이스에서 폼 조회 + const formRecords = await db + .select({ + id: forms.id, + formCode: forms.formCode, + formName: forms.formName, + // tagType: forms.tagType, + }) + .from(forms) + .where(eq(forms.contractItemId, contractItemId)); + + console.log(`[Forms Service] Found ${formRecords.length} forms for contractItemId: ${contractItemId}`); + + // 결과가 배열인지 확인 + if (!Array.isArray(formRecords)) { + getErrorMessage(`Unexpected result format for contractItemId ${contractItemId} ${formRecords}`); + return { forms: [] }; + } + + return { forms: formRecords }; + } catch (error) { + getErrorMessage(`Database error for contractItemId ${contractItemId}: ${error}`); + throw error; // 캐시 함수에서 에러를 던져 캐싱이 발생하지 않도록 함 + } + }, + [cacheKey], + { + // 캐시 시간 단축 + revalidate: 60, // 1분으로 줄임 + tags: [cacheKey] + } + )(); + } catch (error) { + getErrorMessage(`Cache operation failed for contractItemId ${contractItemId}: ${error}`); + + // 캐시 문제 시 직접 쿼리 시도 + try { + console.log(`[Forms Service] Fallback: Direct query for contractItemId: ${contractItemId}`); + + const formRecords = await db + .select({ + id: forms.id, + formCode: forms.formCode, + formName: forms.formName, + // tagType: forms.tagType, + }) + .from(forms) + .where(eq(forms.contractItemId, contractItemId)); + + return { forms: formRecords }; + } catch (dbError) { + getErrorMessage(`Fallback query failed for contractItemId ${contractItemId}:${dbError}`); + return { forms: [] }; + } + } +} + +/** + * 폼 캐시를 갱신하는 서버 액션 + */ +export async function revalidateForms(contractItemId: number) { + if (!contractItemId) return; + + const cacheKey = `forms-${contractItemId}`; + console.log(`[Forms Service] Invalidating cache for ${cacheKey}`); + + try { + revalidateTag(cacheKey); + console.log(`[Forms Service] Cache invalidated for ${cacheKey}`); + } catch (error) { + getErrorMessage(`Failed to invalidate cache for ${cacheKey}: ${error}`); + } +} + +/** + * "가장 최신 1개 row"를 가져오고, + * data가 배열이면 그 배열을 반환, + * 그리고 이 로직 전체를 unstable_cache로 감싸 캐싱. + */ +export async function getFormData(formCode: string, contractItemId: number) { + // 고유 캐시 키 (formCode + contractItemId) + const cacheKey = `form-data-${formCode}-${contractItemId}` + + try { + // 1) unstable_cache로 전체 로직을 감싼다 + const result = await unstable_cache( + async () => { + // --- 기존 로직 시작 --- + // (1) form_metas 조회 (가정상 1개만 존재) + const metaRows = await db + .select() + .from(formMetas) + .where(eq(formMetas.formCode, formCode)) + .orderBy(desc(formMetas.updatedAt)) + .limit(1) + + const meta = metaRows[0] ?? null + if (!meta) { + return { columns: null, data: [] } + } + // (2) form_entries에서 (formCode, contractItemId)에 해당하는 "가장 최신" 한 행 + const entryRows = await db + .select() + .from(formEntries) + .where(and(eq(formEntries.formCode, formCode), eq(formEntries.contractItemId, contractItemId))) + .orderBy(desc(formEntries.updatedAt)) + .limit(1) + + const entry = entryRows[0] ?? null + + // columns: DB에 저장된 JSON (DataTableColumnJSON[]) + const columns = meta.columns as DataTableColumnJSON[] + + columns.forEach((col) => { + // 이미 displayLabel이 있으면 그대로 두고, + // 없으면 uom이 있으면 "label (uom)" 형태, + // 둘 다 없으면 label만 쓴다. + if (!col.displayLabel) { + if (col.uom) { + col.displayLabel = `${col.label} (${col.uom})` + } else { + col.displayLabel = col.label + } + } + }) + + // data: 만약 entry가 없거나, data가 아닌 형태면 빈 배열 + let data: Array<Record<string, any>> = [] + if (entry) { + if (Array.isArray(entry.data)) { + data = entry.data + } else { + console.warn("formEntries data was not an array. Using empty array.") + } + } + + return { columns, data } + // --- 기존 로직 끝 --- + }, + [cacheKey], // 캐시 키 의존성 + { + revalidate: 60, // 1분 캐시 + tags: [cacheKey], // 캐시 태그 + } + )() + + return result + } catch (cacheError) { + console.error(`[getFormData] Cache operation failed:`, cacheError) + + // --- fallback: 캐시 문제 시 직접 쿼리 시도 --- + try { + console.log(`[getFormData] Fallback DB query for (${formCode}, ${contractItemId})`) + + // (1) form_metas + const metaRows = await db + .select() + .from(formMetas) + .where(eq(formMetas.formCode, formCode)) + .orderBy(desc(formMetas.updatedAt)) + .limit(1) + + const meta = metaRows[0] ?? null + if (!meta) { + return { columns: null, data: [] } + } + + // (2) form_entries + const entryRows = await db + .select() + .from(formEntries) + .where(and(eq(formEntries.formCode, formCode), eq(formEntries.contractItemId, contractItemId))) + .orderBy(desc(formEntries.updatedAt)) + .limit(1) + + const entry = entryRows[0] ?? null + + const columns = meta.columns as DataTableColumnJSON[] + + columns.forEach((col) => { + // 이미 displayLabel이 있으면 그대로 두고, + // 없으면 uom이 있으면 "label (uom)" 형태, + // 둘 다 없으면 label만 쓴다. + if (!col.displayLabel) { + if (col.uom) { + col.displayLabel = `${col.label} (${col.uom})` + } else { + col.displayLabel = col.label + } + } + }) + + let data: Array<Record<string, any>> = [] + if (entry) { + if (Array.isArray(entry.data)) { + data = entry.data + } else { + console.warn("formEntries data was not an array. Using empty array (fallback).") + } + } + + return { columns, data } + } catch (dbError) { + console.error(`[getFormData] Fallback DB query failed:`, dbError) + return { columns: null, data: [] } + } + } +} + +// export async function syncMissingTags(contractItemId: number, formCode: string) { + + +// // (1) forms 테이블에서 (contractItemId, formCode) 찾기 +// const [formRow] = await db +// .select() +// .from(forms) +// .where(and(eq(forms.contractItemId, contractItemId), eq(forms.formCode, formCode))) +// .limit(1) + +// if (!formRow) { +// throw new Error(`Form not found for contractItemId=${contractItemId}, formCode=${formCode}`) +// } + +// const { tagType, class: className } = formRow + +// // (2) tags 테이블에서 (contractItemId, tagType, class)인 태그 찾기 +// const tagRows = await db +// .select() +// .from(tags) +// .where( +// and( +// eq(tags.contractItemId, contractItemId), +// eq(tags.tagType, tagType), +// eq(tags.class, className), +// ) +// ) + +// if (tagRows.length === 0) { +// console.log("No matching tags found.") +// return { createdCount: 0 } +// } + +// // (3) formEntries에서 (contractItemId, formCode)인 row 1개 조회 +// let [entry] = await db +// .select() +// .from(formEntries) +// .where( +// and( +// eq(formEntries.contractItemId, contractItemId), +// eq(formEntries.formCode, formCode) +// ) +// ) +// .limit(1) + +// // (4) 만약 없다면 새로 insert: data = [] +// if (!entry) { +// const [inserted] = await db.insert(formEntries).values({ +// contractItemId, +// formCode, +// data: [], // 초기 상태는 빈 배열 +// }).returning() +// entry = inserted +// } + +// // entry.data는 배열이라고 가정 +// // Drizzle에서 jsonb는 JS object로 파싱되어 들어오므로, 타입 캐스팅 +// const existingData = entry.data as Array<{ tagNumber: string }> +// let createdCount = 0 + +// // (5) tagRows 각각에 대해, 이미 배열에 존재하는지 확인 후 없으면 push +// const updatedArray = [...existingData] +// for (const tagRow of tagRows) { +// const tagNo = tagRow.tagNo +// const found = updatedArray.some(item => item.tagNumber === tagNo) +// if (!found) { +// updatedArray.push({ tagNumber: tagNo }) +// createdCount++ +// } +// } + +// // (6) 변경이 있으면 UPDATE +// if (createdCount > 0) { +// await db +// .update(formEntries) +// .set({ data: updatedArray }) +// .where(eq(formEntries.id, entry.id)) +// } + + +// revalidateTag(`form-data-${formCode}-${contractItemId}`); + +// return { createdCount } +// } + +export async function syncMissingTags(contractItemId: number, formCode: string) { + // (1) Ensure there's a row in `forms` matching (contractItemId, formCode). + const [formRow] = await db + .select() + .from(forms) + .where( + and(eq(forms.contractItemId, contractItemId), eq(forms.formCode, formCode)) + ) + .limit(1) + + if (!formRow) { + throw new Error( + `Form not found for contractItemId=${contractItemId}, formCode=${formCode}` + ) + } + + // (2) Get all mappings from `tagTypeClassFormMappings` for this formCode. + const formMappings = await db + .select() + .from(tagTypeClassFormMappings) + .where(eq(tagTypeClassFormMappings.formCode, formCode)) + + // If no mappings are found, there's nothing to sync. + if (formMappings.length === 0) { + console.log(`No mappings found for formCode=${formCode}`) + return { createdCount: 0, updatedCount: 0, deletedCount: 0 } + } + + // Build a dynamic OR clause to match (tagType, class) pairs from the mappings. + const orConditions = formMappings.map((m) => + and(eq(tags.tagType, m.tagTypeLabel), eq(tags.class, m.classLabel)) + ) + + // (3) Fetch all matching `tags` for the contractItemId + any of the (tagType, class) pairs. + const tagRows = await db + .select() + .from(tags) + .where(and(eq(tags.contractItemId, contractItemId), or(...orConditions))) + + // (4) Fetch (or create) a single `formEntries` row for (contractItemId, formCode). + let [entry] = await db + .select() + .from(formEntries) + .where( + and( + eq(formEntries.contractItemId, contractItemId), + eq(formEntries.formCode, formCode) + ) + ) + .limit(1) + + if (!entry) { + const [inserted] = await db + .insert(formEntries) + .values({ + contractItemId, + formCode, + data: [], // Initialize with empty array + }) + .returning() + entry = inserted + } + + // entry.data는 [{ tagNumber: string, tagDescription?: string }, ...] 형태라고 가정 + const existingData = entry.data as Array<{ + tagNumber: string + tagDescription?: string + }> + + // Create a Set of valid tagNumbers from tagRows for efficient lookup + const validTagNumbers = new Set(tagRows.map(tag => tag.tagNo)) + + // Copy existing data to work with + let updatedData: Array<{ + tagNumber: string + tagDescription?: string + }> = [] + + let createdCount = 0 + let updatedCount = 0 + let deletedCount = 0 + + // First, filter out items that should be deleted (not in validTagNumbers) + for (const item of existingData) { + if (validTagNumbers.has(item.tagNumber)) { + updatedData.push(item) + } else { + deletedCount++ + } + } + + // (5) For each tagRow, if it's missing in updatedData, push it in. + // 이미 있는 경우에도 description이 달라지면 업데이트할 수 있음. + for (const tagRow of tagRows) { + const { tagNo, description } = tagRow + + // 5-1. 기존 데이터에서 tagNumber 매칭 + const existingIndex = updatedData.findIndex( + (item) => item.tagNumber === tagNo + ) + + // 5-2. 없다면 새로 추가 + if (existingIndex === -1) { + updatedData.push({ + tagNumber: tagNo, + tagDescription: description ?? "", + }) + createdCount++ + } else { + // 5-3. 이미 있으면, description이 다를 때만 업데이트(선택 사항) + const existingItem = updatedData[existingIndex] + if (existingItem.tagDescription !== description) { + updatedData[existingIndex] = { + ...existingItem, + tagDescription: description ?? "", + } + updatedCount++ + } + } + } + + // (6) 실제로 추가되거나 수정되거나 삭제된 게 있다면 DB에 반영 + if (createdCount > 0 || updatedCount > 0 || deletedCount > 0) { + await db + .update(formEntries) + .set({ data: updatedData }) + .where(eq(formEntries.id, entry.id)) + } + + // 캐시 무효화 등 후처리 + revalidateTag(`form-data-${formCode}-${contractItemId}`) + + return { createdCount, updatedCount, deletedCount } +} + +/** + * updateFormDataInDB: + * (formCode, contractItemId)에 해당하는 "단 하나의" formEntries row를 가져와, + * data: [{ tagNumber, ...}, ...] 배열에서 tagNumber 매칭되는 항목을 업데이트 + * 업데이트 후, revalidateTag()로 캐시 무효화. + */ +type UpdateResponse = { + success: boolean + message: string + data?: any +} + +export async function updateFormDataInDB( + formCode: string, + contractItemId: number, + newData: Record<string, any> +): Promise<UpdateResponse> { + try { + // 1) tagNumber로 식별 + const tagNumber = newData.tagNumber + if (!tagNumber) { + return { + success: false, + message: "tagNumber는 필수 항목입니다." + } + } + + // 2) row 찾기 (단 하나) + const entries = await db + .select() + .from(formEntries) + .where( + and( + eq(formEntries.formCode, formCode), + eq(formEntries.contractItemId, contractItemId) + ) + ) + .limit(1) + + if (!entries || entries.length === 0) { + return { + success: false, + message: `폼 데이터를 찾을 수 없습니다. (formCode=${formCode}, contractItemId=${contractItemId})` + } + } + + const entry = entries[0] + + // 3) data가 배열인지 확인 + if (!entry.data) { + return { + success: false, + message: "폼 데이터가 없습니다." + } + } + + const dataArray = entry.data as Array<Record<string, any>> + if (!Array.isArray(dataArray)) { + return { + success: false, + message: "폼 데이터가 올바른 형식이 아닙니다. 배열 형식이어야 합니다." + } + } + + // 4) tagNumber = newData.tagNumber 항목 찾기 + const idx = dataArray.findIndex((item) => item.tagNumber === tagNumber) + if (idx < 0) { + return { + success: false, + message: `태그 번호 "${tagNumber}"를 가진 항목을 찾을 수 없습니다.` + } + } + + // 5) 병합 + const oldItem = dataArray[idx] + const updatedItem = { + ...oldItem, + ...newData, + tagNumber: oldItem.tagNumber, // tagNumber 변경 불가 시 유지 + } + + const updatedArray = [...dataArray] + updatedArray[idx] = updatedItem + + // 6) DB UPDATE + try { + await db + .update(formEntries) + .set({ + data: updatedArray, + updatedAt: new Date() // 업데이트 시간도 갱신 + }) + .where(eq(formEntries.id, entry.id)) + } catch (dbError) { + console.error("Database update error:", dbError) + + if (dbError instanceof DrizzleError) { + return { + success: false, + message: `데이터베이스 업데이트 오류: ${dbError.message}` + } + } + + return { + success: false, + message: "데이터베이스 업데이트 중 오류가 발생했습니다." + } + } + + // 7) Cache 무효화 + try { + // 캐시 태그를 form-data-${formCode}-${contractItemId} 형태로 가정 + const cacheTag = `form-data-${formCode}-${contractItemId}` + revalidateTag(cacheTag) + } catch (cacheError) { + console.warn("Cache revalidation warning:", cacheError) + // 캐시 무효화는 실패해도 업데이트 자체는 성공했으므로 경고만 로그로 남김 + } + + return { + success: true, + message: '데이터가 성공적으로 업데이트되었습니다.', + data: { + tagNumber, + updatedFields: Object.keys(newData).filter(key => key !== 'tagNumber') + } + } + } catch (error) { + // 예상치 못한 오류 처리 + console.error("Unexpected error in updateFormDataInDB:", error) + return { + success: false, + message: error instanceof Error + ? `예상치 못한 오류가 발생했습니다: ${error.message}` + : "알 수 없는 오류가 발생했습니다." + } + } +} + +// FormColumn Type (동일) +export interface FormColumn { + key: string + type: string + label: string + options?: string[] +} + +interface MetadataResult { + formName: string + formCode: string + columns: FormColumn[] +} + +/** + * 서버 액션: + * 주어진 formCode에 해당하는 form_metas 레코드 1개를 찾아서 + * { formName, formCode, columns } 형태로 반환. + * 없으면 null. + */ +export async function fetchFormMetadata(formCode: string): Promise<MetadataResult | null> { + try { + // 기존 방식: select().from().where() + const rows = await db + .select() + .from(formMetas) + .where(eq(formMetas.formCode, formCode)) + .limit(1) + + // rows는 배열 + const metaData = rows[0] + if (!metaData) return null + + return { + formCode: metaData.formCode, + formName: metaData.formName, + columns: metaData.columns as FormColumn[] + } + } catch (err) { + console.error("Error in fetchFormMetadata:", err) + return null + } +}
\ No newline at end of file diff --git a/lib/handle-error.ts b/lib/handle-error.ts new file mode 100644 index 00000000..1f608723 --- /dev/null +++ b/lib/handle-error.ts @@ -0,0 +1,22 @@ +import { toast } from "sonner" +import { z } from "zod" + +export function getErrorMessage(err: unknown) { + const unknownError = "Something went wrong, please try again later." + + if (err instanceof z.ZodError) { + const errors = err.issues.map((issue) => { + return issue.message + }) + return errors.join("\n") + } else if (err instanceof Error) { + return err.message + } else { + return unknownError + } +} + +export function showErrorToast(err: unknown) { + const errorMessage = getErrorMessage(err) + return toast.error(errorMessage) +} diff --git a/lib/id.ts b/lib/id.ts new file mode 100644 index 00000000..e6e44cbd --- /dev/null +++ b/lib/id.ts @@ -0,0 +1,43 @@ +import { customAlphabet } from "nanoid" + +const prefixes = { + task: "tsk", +} + +interface GenerateIdOptions { + /** + * The length of the generated ID. + * @default 12 + * @example 12 => "abc123def456" + * */ + length?: number + /** + * The separator to use between the prefix and the generated ID. + * @default "_" + * @example "_" => "str_abc123" + * */ + separator?: string +} + +/** + * Generates a unique ID with optional prefix and configuration. + * @param prefixOrOptions The prefix string or options object + * @param options The options for generating the ID + */ +export function generateId( + prefixOrOptions?: keyof typeof prefixes | GenerateIdOptions, + options: GenerateIdOptions = {} +) { + if (typeof prefixOrOptions === "object") { + options = prefixOrOptions + prefixOrOptions = undefined + } + + const { length = 12, separator = "_" } = options + const id = customAlphabet( + "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", + length + )() + + return prefixOrOptions ? `${prefixes[prefixOrOptions]}${separator}${id}` : id +} diff --git a/lib/items/repository.ts b/lib/items/repository.ts new file mode 100644 index 00000000..550e6b1d --- /dev/null +++ b/lib/items/repository.ts @@ -0,0 +1,125 @@ +// src/lib/items/repository.ts +import db from "@/db/db"; +import { Item, items } from "@/db/schema/items"; +import { + eq, + inArray, + not, + asc, + desc, + and, + ilike, + gte, + lte, + count, + gt, +} from "drizzle-orm"; +import { PgTransaction } from "drizzle-orm/pg-core"; +export type NewItem = typeof items.$inferInsert + +/** + * 단건/복수 조회 시 공통으로 사용 가능한 SELECT 함수 예시 + * - 트랜잭션(tx)을 받아서 사용하도록 구현 + */ +export async function selectItems( + tx: PgTransaction<any, any, any>, + params: { + where?: any; // drizzle-orm의 조건식 (and, eq...) 등 + orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[]; + offset?: number; + limit?: number; + } +) { + const { where, orderBy, offset = 0, limit = 10 } = params; + + return tx + .select() + .from(items) + .where(where) + .orderBy(...(orderBy ?? [])) + .offset(offset) + .limit(limit); +} +/** 총 개수 count */ +export async function countItems( + tx: PgTransaction<any, any, any>, + where?: any +) { + const res = await tx.select({ count: count() }).from(items).where(where); + return res[0]?.count ?? 0; +} + +/** 단건 Insert 예시 */ +export async function insertItem( + tx: PgTransaction<any, any, any>, + data: NewItem // DB와 동일한 insert 가능한 타입 +) { + // returning() 사용 시 배열로 돌아오므로 [0]만 리턴 + return tx + .insert(items) + .values(data) + .returning({ id: items.id, createdAt: items.createdAt }); +} + +/** 복수 Insert 예시 */ +export async function insertItems( + tx: PgTransaction<any, any, any>, + data: Item[] +) { + return tx.insert(items).values(data).onConflictDoNothing(); +} + + + +/** 단건 삭제 */ +export async function deleteItemById( + tx: PgTransaction<any, any, any>, + itemId: number +) { + return tx.delete(items).where(eq(items.id, itemId)); +} + +/** 복수 삭제 */ +export async function deleteItemsByIds( + tx: PgTransaction<any, any, any>, + ids: number[] +) { + return tx.delete(items).where(inArray(items.id, ids)); +} + +/** 전체 삭제 */ +export async function deleteAllItems( + tx: PgTransaction<any, any, any>, +) { + return tx.delete(items); +} + +/** 단건 업데이트 */ +export async function updateItem( + tx: PgTransaction<any, any, any>, + itemId: number, + data: Partial<Item> +) { + return tx + .update(items) + .set(data) + .where(eq(items.id, itemId)) + .returning({ id: items.id, createdAt: items.createdAt }); +} + +/** 복수 업데이트 */ +export async function updateItems( + tx: PgTransaction<any, any, any>, + ids: number[], + data: Partial<Item> +) { + return tx + .update(items) + .set(data) + .where(inArray(items.id, ids)) + .returning({ id: items.id, createdAt: items.createdAt }); +} + +export async function findAllItems(): Promise<Item[]> { + return db.select().from(items).orderBy(asc(items.itemCode)); +} diff --git a/lib/items/service.ts b/lib/items/service.ts new file mode 100644 index 00000000..ef14a5f0 --- /dev/null +++ b/lib/items/service.ts @@ -0,0 +1,201 @@ +// src/lib/items/service.ts +"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택) + +import { revalidateTag, unstable_noStore } from "next/cache"; +import db from "@/db/db"; +import { customAlphabet } from "nanoid"; + +import { filterColumns } from "@/lib/filter-columns"; +import { unstable_cache } from "@/lib/unstable-cache"; +import { getErrorMessage } from "@/lib/handle-error"; + +import { asc, desc, ilike, inArray, and, gte, lte, not, or } from "drizzle-orm"; +import { CreateItemSchema, GetItemsSchema, UpdateItemSchema } from "./validations"; +import { Item, items } from "@/db/schema/items"; +import { countItems, deleteItemById, deleteItemsByIds, findAllItems, insertItem, selectItems, updateItem } from "./repository"; + + +/* ----------------------------------------------------- + 1) 조회 관련 +----------------------------------------------------- */ + +/** + * 복잡한 조건으로 Item 목록을 조회 (+ pagination) 하고, + * 총 개수에 따라 pageCount를 계산해서 리턴. + * Next.js의 unstable_cache를 사용해 일정 시간 캐시. + */ +export async function getItems(input: GetItemsSchema) { + + return unstable_cache( + async () => { + try { + const offset = (input.page - 1) * input.perPage; + + // const advancedTable = input.flags.includes("advancedTable"); + const advancedTable = true; + + // advancedTable 모드면 filterColumns()로 where 절 구성 + const advancedWhere = filterColumns({ + table: items, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + + let globalWhere + if (input.search) { + const s = `%${input.search}%` + globalWhere = or(ilike(items.itemCode, s), ilike(items.itemName, s) + , ilike(items.description, s) + ) + // 필요시 여러 칼럼 OR조건 (status, priority, etc) + } + + const finalWhere = and( + // advancedWhere or your existing conditions + advancedWhere, + globalWhere // and()함수로 결합 or or() 등으로 결합 + ) + + + // 아니면 ilike, inArray, gte 등으로 where 절 구성 + const where = finalWhere + + + const orderBy = + input.sort.length > 0 + ? input.sort.map((item) => + item.desc ? desc(items[item.id]) : asc(items[item.id]) + ) + : [asc(items.createdAt)]; + + // 트랜잭션 내부에서 Repository 호출 + const { data, total } = await db.transaction(async (tx) => { + const data = await selectItems(tx, { + where, + orderBy, + offset, + limit: input.perPage, + }); + + const total = await countItems(tx, where); + return { data, total }; + }); + + const pageCount = Math.ceil(total / input.perPage); + + return { data, pageCount }; + } catch (err) { + // 에러 발생 시 디폴트 + return { data: [], pageCount: 0 }; + } + }, + [JSON.stringify(input)], // 캐싱 키 + { + revalidate: 3600, + tags: ["items"], // revalidateTag("items") 호출 시 무효화 + } + )(); +} + + +/* ----------------------------------------------------- + 2) 생성(Create) +----------------------------------------------------- */ + + +/** + * Item 생성 후, (가장 오래된 Item 1개) 삭제로 + * 전체 Item 개수를 고정 + */ +export async function createItem(input: CreateItemSchema) { + unstable_noStore(); // Next.js 서버 액션 캐싱 방지 + try { + await db.transaction(async (tx) => { + // 새 Item 생성 + const [newTask] = await insertItem(tx, { + itemCode: input.itemCode, + itemName: input.itemName, + description: input.description, + }); + return newTask; + + }); + + // 캐시 무효화 + revalidateTag("items"); + + return { data: null, error: null }; + } catch (err) { + return { data: null, error: getErrorMessage(err) }; + } +} + +/* ----------------------------------------------------- + 3) 업데이트 +----------------------------------------------------- */ + +/** 단건 업데이트 */ +export async function modifyItem(input: UpdateItemSchema & { id: number }) { + unstable_noStore(); + try { + const data = await db.transaction(async (tx) => { + const [res] = await updateItem(tx, input.id, { + itemCode: input.itemCode, + itemName: input.itemName, + description: input.description, + }); + return res; + }); + + revalidateTag("items"); + return { data: null, error: null }; + } catch (err) { + return { data: null, error: getErrorMessage(err) }; + } +} + + + +/** 단건 삭제 */ +export async function removeItem(input: { id: number }) { + unstable_noStore(); + try { + await db.transaction(async (tx) => { + // 삭제 + await deleteItemById(tx, input.id); + // 바로 새 Item 생성 + }); + + revalidateTag("items"); + + return { data: null, error: null }; + } catch (err) { + return { data: null, error: getErrorMessage(err) }; + } +} + +/** 복수 삭제 */ +export async function removeItems(input: { ids: number[] }) { + unstable_noStore(); + try { + await db.transaction(async (tx) => { + // 삭제 + await deleteItemsByIds(tx, input.ids); + }); + + revalidateTag("items"); + + return { data: null, error: null }; + } catch (err) { + return { data: null, error: getErrorMessage(err) }; + } +} + +export async function getAllItems(): Promise<Item[]> { + try { + return await findAllItems(); + } catch (err) { + throw new Error("Failed to get roles"); + } +} diff --git a/lib/items/table/add-items-dialog.tsx b/lib/items/table/add-items-dialog.tsx new file mode 100644 index 00000000..2224444c --- /dev/null +++ b/lib/items/table/add-items-dialog.tsx @@ -0,0 +1,156 @@ +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" + +import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +// react-hook-form + shadcn/ui Form +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +// shadcn/ui Select +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { createItemSchema, CreateItemSchema } from "../validations" +import { createItem } from "../service" +import { Textarea } from "@/components/ui/textarea" + + + +export function AddItemDialog() { + const [open, setOpen] = React.useState(false) + + // react-hook-form 세팅 + const form = useForm<CreateItemSchema>({ + resolver: zodResolver(createItemSchema), + defaultValues: { + itemCode: "", + itemName: "", + description: "", + }, + }) + + async function onSubmit(data: CreateItemSchema) { + const result = await createItem(data) + if (result.error) { + alert(`에러: ${result.error}`) + return + } + // 성공 시 모달 닫고 폼 리셋 + form.reset() + setOpen(false) + } + + function handleDialogOpenChange(nextOpen: boolean) { + if (!nextOpen) { + form.reset() + } + setOpen(nextOpen) + } + + return ( + <Dialog open={open} onOpenChange={handleDialogOpenChange}> + {/* 모달을 열기 위한 버튼 */} + <DialogTrigger asChild> + <Button variant="default" size="sm"> + Add Item + </Button> + </DialogTrigger> + + <DialogContent> + <DialogHeader> + <DialogTitle>Create New Item</DialogTitle> + <DialogDescription> + 새 Item 정보를 입력하고 <b>Create</b> 버튼을 누르세요. + </DialogDescription> + </DialogHeader> + + {/* shadcn/ui Form을 이용해 react-hook-form과 연결 */} + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)}> + <div className="space-y-4 py-4"> + + <FormField + control={form.control} + name="itemCode" + render={({ field }) => ( + <FormItem> + <FormLabel>Item Code</FormLabel> + <FormControl> + <Input + placeholder="e.g." + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="itemName" + render={({ field }) => ( + <FormItem> + <FormLabel>Item Name</FormLabel> + <FormControl> + <Input + placeholder="e.g." + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="description" + render={({ field }) => ( + <FormItem> + <FormLabel>Description</FormLabel> + <FormControl> + <Textarea + placeholder="e.g." + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + </div> + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => setOpen(false)} + > + Cancel + </Button> + <Button type="submit" disabled={form.formState.isSubmitting}> + Create + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/items/table/delete-items-dialog.tsx b/lib/items/table/delete-items-dialog.tsx new file mode 100644 index 00000000..25ae265b --- /dev/null +++ b/lib/items/table/delete-items-dialog.tsx @@ -0,0 +1,149 @@ +"use client" + +import * as React from "react" +import { type Row } from "@tanstack/react-table" +import { Loader, Trash } from "lucide-react" +import { toast } from "sonner" + +import { useMediaQuery } from "@/hooks/use-media-query" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer" + +import { Item } from "@/db/schema/items" +import { removeItems } from "../service" + +interface DeleteItemsDialogProps + extends React.ComponentPropsWithoutRef<typeof Dialog> { + items: Row<Item>["original"][] + showTrigger?: boolean + onSuccess?: () => void +} + +export function DeleteItemsDialog({ + items, + showTrigger = true, + onSuccess, + ...props +}: DeleteItemsDialogProps) { + const [isDeletePending, startDeleteTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + + function onDelete() { + startDeleteTransition(async () => { + const { error } = await removeItems({ + ids: items.map((item) => item.id), + }) + + if (error) { + toast.error(error) + return + } + + props.onOpenChange?.(false) + toast.success("Tasks deleted") + onSuccess?.() + }) + } + + if (isDesktop) { + return ( + <Dialog {...props}> + {showTrigger ? ( + <DialogTrigger asChild> + <Button variant="outline" size="sm"> + <Trash className="mr-2 size-4" aria-hidden="true" /> + Delete ({items.length}) + </Button> + </DialogTrigger> + ) : null} + <DialogContent> + <DialogHeader> + <DialogTitle>Are you absolutely sure?</DialogTitle> + <DialogDescription> + This action cannot be undone. This will permanently delete your{" "} + <span className="font-medium">{items.length}</span> + {items.length === 1 ? " task" : " tasks"} from our servers. + </DialogDescription> + </DialogHeader> + <DialogFooter className="gap-2 sm:space-x-0"> + <DialogClose asChild> + <Button variant="outline">Cancel</Button> + </DialogClose> + <Button + aria-label="Delete selected rows" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending} + > + {isDeletePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + Delete + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) + } + + return ( + <Drawer {...props}> + {showTrigger ? ( + <DrawerTrigger asChild> + <Button variant="outline" size="sm"> + <Trash className="mr-2 size-4" aria-hidden="true" /> + Delete ({items.length}) + </Button> + </DrawerTrigger> + ) : null} + <DrawerContent> + <DrawerHeader> + <DrawerTitle>Are you absolutely sure?</DrawerTitle> + <DrawerDescription> + This action cannot be undone. This will permanently delete your{" "} + <span className="font-medium">{items.length}</span> + {items.length === 1 ? " item" : " items"} from our servers. + </DrawerDescription> + </DrawerHeader> + <DrawerFooter className="gap-2 sm:space-x-0"> + <DrawerClose asChild> + <Button variant="outline">Cancel</Button> + </DrawerClose> + <Button + aria-label="Delete selected rows" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending} + > + {isDeletePending && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + Delete + </Button> + </DrawerFooter> + </DrawerContent> + </Drawer> + ) +} diff --git a/lib/items/table/feature-flags-provider.tsx b/lib/items/table/feature-flags-provider.tsx new file mode 100644 index 00000000..81131894 --- /dev/null +++ b/lib/items/table/feature-flags-provider.tsx @@ -0,0 +1,108 @@ +"use client" + +import * as React from "react" +import { useQueryState } from "nuqs" + +import { dataTableConfig, type DataTableConfig } from "@/config/data-table" +import { cn } from "@/lib/utils" +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" + +type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"] + +interface FeatureFlagsContextProps { + featureFlags: FeatureFlagValue[] + setFeatureFlags: (value: FeatureFlagValue[]) => void +} + +const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({ + featureFlags: [], + setFeatureFlags: () => {}, +}) + +export function useFeatureFlags() { + const context = React.useContext(FeatureFlagsContext) + if (!context) { + throw new Error( + "useFeatureFlags must be used within a FeatureFlagsProvider" + ) + } + return context +} + +interface FeatureFlagsProviderProps { + children: React.ReactNode +} + +export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) { + const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>( + "flags", + { + defaultValue: [], + parse: (value) => value.split(",") as FeatureFlagValue[], + serialize: (value) => value.join(","), + eq: (a, b) => + a.length === b.length && a.every((value, index) => value === b[index]), + clearOnDefault: true, + shallow: false, + } + ) + + return ( + <FeatureFlagsContext.Provider + value={{ + featureFlags, + setFeatureFlags: (value) => void setFeatureFlags(value), + }} + > + <div className="w-full overflow-x-auto"> + <ToggleGroup + type="multiple" + variant="outline" + size="sm" + value={featureFlags} + onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)} + className="w-fit gap-0" + > + {dataTableConfig.featureFlags.map((flag, index) => ( + <Tooltip key={flag.value}> + <ToggleGroupItem + value={flag.value} + className={cn( + "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90", + { + "rounded-l-sm border-r-0": index === 0, + "rounded-r-sm": + index === dataTableConfig.featureFlags.length - 1, + } + )} + asChild + > + <TooltipTrigger> + <flag.icon className="size-3.5 shrink-0" aria-hidden="true" /> + {flag.label} + </TooltipTrigger> + </ToggleGroupItem> + <TooltipContent + align="start" + side="bottom" + sideOffset={6} + className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground" + > + <div>{flag.tooltipTitle}</div> + <div className="text-xs text-muted-foreground"> + {flag.tooltipDescription} + </div> + </TooltipContent> + </Tooltip> + ))} + </ToggleGroup> + </div> + {children} + </FeatureFlagsContext.Provider> + ) +} diff --git a/lib/items/table/feature-flags.tsx b/lib/items/table/feature-flags.tsx new file mode 100644 index 00000000..aaae6af2 --- /dev/null +++ b/lib/items/table/feature-flags.tsx @@ -0,0 +1,96 @@ +"use client" + +import * as React from "react" +import { useQueryState } from "nuqs" + +import { dataTableConfig, type DataTableConfig } from "@/config/data-table" +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" + +type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"] + +interface TasksTableContextProps { + featureFlags: FeatureFlagValue[] + setFeatureFlags: (value: FeatureFlagValue[]) => void +} + +const TasksTableContext = React.createContext<TasksTableContextProps>({ + featureFlags: [], + setFeatureFlags: () => {}, +}) + +export function useTasksTable() { + const context = React.useContext(TasksTableContext) + if (!context) { + throw new Error("useTasksTable must be used within a TasksTableProvider") + } + return context +} + +export function TasksTableProvider({ children }: React.PropsWithChildren) { + const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>( + "featureFlags", + { + defaultValue: [], + parse: (value) => value.split(",") as FeatureFlagValue[], + serialize: (value) => value.join(","), + eq: (a, b) => + a.length === b.length && a.every((value, index) => value === b[index]), + clearOnDefault: true, + } + ) + + return ( + <TasksTableContext.Provider + value={{ + featureFlags, + setFeatureFlags: (value) => void setFeatureFlags(value), + }} + > + <div className="w-full overflow-x-auto"> + <ToggleGroup + type="multiple" + variant="outline" + size="sm" + value={featureFlags} + onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)} + className="w-fit" + > + {dataTableConfig.featureFlags.map((flag) => ( + <Tooltip key={flag.value}> + <ToggleGroupItem + value={flag.value} + className="whitespace-nowrap px-3 text-xs" + asChild + > + <TooltipTrigger> + <flag.icon + className="mr-2 size-3.5 shrink-0" + aria-hidden="true" + /> + {flag.label} + </TooltipTrigger> + </ToggleGroupItem> + <TooltipContent + align="start" + side="bottom" + sideOffset={6} + className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground" + > + <div>{flag.tooltipTitle}</div> + <div className="text-xs text-muted-foreground"> + {flag.tooltipDescription} + </div> + </TooltipContent> + </Tooltip> + ))} + </ToggleGroup> + </div> + {children} + </TasksTableContext.Provider> + ) +} diff --git a/lib/items/table/items-table-columns.tsx b/lib/items/table/items-table-columns.tsx new file mode 100644 index 00000000..60043e8e --- /dev/null +++ b/lib/items/table/items-table-columns.tsx @@ -0,0 +1,183 @@ +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { Ellipsis } from "lucide-react" +import { toast } from "sonner" + +import { getErrorMessage } from "@/lib/handle-error" +import { formatDate } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header" + +import { modifiTask } from "@/lib/tasks/service" + + +import { itemsColumnsConfig } from "@/config/itemsColumnsConfig" +import { Item } from "@/db/schema/items" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<Item> | null>> +} + +/** + * tanstack table 컬럼 정의 (중첩 헤더 버전) + */ +export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<Item>[] { + // ---------------------------------------------------------------- + // 1) select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef<Item> = { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + size:40, + enableSorting: false, + enableHiding: false, + } + + // ---------------------------------------------------------------- + // 2) actions 컬럼 (Dropdown 메뉴) + // ---------------------------------------------------------------- + const actionsColumn: ColumnDef<Item> = { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-8 p-0 data-[state=open]:bg-muted" + > + <Ellipsis className="size-4" aria-hidden="true" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-40"> + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "update" })} + > + Edit + </DropdownMenuItem> + + <DropdownMenuSeparator /> + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "delete" })} + > + Delete + <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ) + }, + size: 40, + } + + // ---------------------------------------------------------------- + // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 + // ---------------------------------------------------------------- + // 3-1) groupMap: { [groupName]: ColumnDef<Item>[] } + const groupMap: Record<string, ColumnDef<Item>[]> = {} + + itemsColumnsConfig.forEach((cfg) => { + // 만약 group가 없으면 "_noGroup" 처리 + const groupName = cfg.group || "_noGroup" + + if (!groupMap[groupName]) { + groupMap[groupName] = [] + } + + // child column 정의 + const childCol: ColumnDef<Item> = { + accessorKey: cfg.id, + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title={cfg.label} /> + ), + meta: { + excelHeader: cfg.excelHeader, + group: cfg.group, + type: cfg.type, + }, + cell: ({ row, cell }) => { + + if (cfg.id === "createdAt"||cfg.id === "updatedAt") { + const dateVal = cell.getValue() as Date + return formatDate(dateVal) + } + + return row.getValue(cfg.id) ?? "" + }, + } + + groupMap[groupName].push(childCol) + }) + + // ---------------------------------------------------------------- + // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기 + // ---------------------------------------------------------------- + const nestedColumns: ColumnDef<Item>[] = [] + + // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함 + // 여기서는 그냥 Object.entries 순서 + Object.entries(groupMap).forEach(([groupName, colDefs]) => { + if (groupName === "_noGroup") { + // 그룹 없음 → 그냥 최상위 레벨 컬럼 + nestedColumns.push(...colDefs) + } else { + // 상위 컬럼 + nestedColumns.push({ + id: groupName, + header: groupName, // "Basic Info", "Metadata" 등 + columns: colDefs, + }) + } + }) + + // ---------------------------------------------------------------- + // 4) 최종 컬럼 배열: select, nestedColumns, actions + // ---------------------------------------------------------------- + return [ + selectColumn, + ...nestedColumns, + actionsColumn, + ] +}
\ No newline at end of file diff --git a/lib/items/table/items-table-toolbar-actions.tsx b/lib/items/table/items-table-toolbar-actions.tsx new file mode 100644 index 00000000..3444daab --- /dev/null +++ b/lib/items/table/items-table-toolbar-actions.tsx @@ -0,0 +1,67 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Download, Upload } from "lucide-react" +import { toast } from "sonner" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" + + +// 만약 서버 액션이나 API 라우트를 이용해 업로드 처리한다면 import +import { importTasksExcel } from "@/lib/tasks/service" // 예시 +import { Item } from "@/db/schema/items" +import { DeleteItemsDialog } from "./delete-items-dialog" +import { AddItemDialog } from "./add-items-dialog" + +interface ItemsTableToolbarActionsProps { + table: Table<Item> +} + +export function ItemsTableToolbarActions({ table }: ItemsTableToolbarActionsProps) { + // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식 + const fileInputRef = React.useRef<HTMLInputElement>(null) + + + + function handleImportClick() { + // 숨겨진 <input type="file" /> 요소를 클릭 + fileInputRef.current?.click() + } + + return ( + <div className="flex items-center gap-2"> + {/** 1) 선택된 로우가 있으면 삭제 다이얼로그 */} + {table.getFilteredSelectedRowModel().rows.length > 0 ? ( + <DeleteItemsDialog + items={table + .getFilteredSelectedRowModel() + .rows.map((row) => row.original)} + onSuccess={() => table.toggleAllRowsSelected(false)} + /> + ) : null} + + {/** 2) 새 Task 추가 다이얼로그 */} + <AddItemDialog /> + + + + {/** 4) Export 버튼 */} + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(table, { + filename: "tasks", + excludeColumns: ["select", "actions"], + }) + } + className="gap-2" + > + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Export</span> + </Button> + </div> + ) +}
\ No newline at end of file diff --git a/lib/items/table/items-table.tsx b/lib/items/table/items-table.tsx new file mode 100644 index 00000000..bbbafc2f --- /dev/null +++ b/lib/items/table/items-table.tsx @@ -0,0 +1,139 @@ +"use client" + +import * as React from "react" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" + +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import { useFeatureFlags } from "./feature-flags-provider" + +import { getItems } from "../service" +import { Item } from "@/db/schema/items" +import { getColumns } from "./items-table-columns" +import { ItemsTableToolbarActions } from "./items-table-toolbar-actions" +import { UpdateItemSheet } from "./update-item-sheet" +import { DeleteItemsDialog } from "./delete-items-dialog" + +interface ItemsTableProps { + promises: Promise< + [ + Awaited<ReturnType<typeof getItems>>, + ] + > +} + +export function ItemsTable({ promises }: ItemsTableProps) { + const { featureFlags } = useFeatureFlags() + + const [{ data, pageCount }] = + React.use(promises) + + console.log(data) + + + const [rowAction, setRowAction] = + React.useState<DataTableRowAction<Item> | null>(null) + + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + /** + * This component can render either a faceted filter or a search filter based on the `options` prop. + * + * @prop options - An array of objects, each representing a filter option. If provided, a faceted filter is rendered. If not, a search filter is rendered. + * + * Each `option` object has the following properties: + * @prop {string} label - The label for the filter option. + * @prop {string} value - The value for the filter option. + * @prop {React.ReactNode} [icon] - An optional icon to display next to the label. + * @prop {boolean} [withCount] - An optional boolean to display the count of the filter option. + */ + const filterFields: DataTableFilterField<Item>[] = [ + { + id: "itemCode", + label: "Item Code", + }, + ] + + /** + * Advanced filter fields for the data table. + * These fields provide more complex filtering options compared to the regular filterFields. + * + * Key differences from regular filterFields: + * 1. More field types: Includes 'text', 'multi-select', 'date', and 'boolean'. + * 2. Enhanced flexibility: Allows for more precise and varied filtering options. + * 3. Used with DataTableAdvancedToolbar: Enables a more sophisticated filtering UI. + * 4. Date and boolean types: Adds support for filtering by date ranges and boolean values. + */ + const advancedFilterFields: DataTableAdvancedFilterField<Item>[] = [ + { + id: "itemCode", + label: "Item Code", + type: "text", + }, + { + id: "itemName", + label: "Item Name", + type: "text", + }, { + id: "description", + label: "Description", + type: "text", + }, + ] + + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + <DataTable + table={table} + + > + + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <ItemsTableToolbarActions table={table} /> + </DataTableAdvancedToolbar> + + </DataTable> + <UpdateItemSheet + open={rowAction?.type === "update"} + onOpenChange={() => setRowAction(null)} + item={rowAction?.row.original ?? null} + /> + <DeleteItemsDialog + open={rowAction?.type === "delete"} + onOpenChange={() => setRowAction(null)} + items={rowAction?.row.original ? [rowAction?.row.original] : []} + showTrigger={false} + onSuccess={() => rowAction?.row.toggleSelected(false)} + /> + </> + ) +} diff --git a/lib/items/table/update-item-sheet.tsx b/lib/items/table/update-item-sheet.tsx new file mode 100644 index 00000000..4bcdbfcb --- /dev/null +++ b/lib/items/table/update-item-sheet.tsx @@ -0,0 +1,178 @@ +"use client" + +import * as React from "react" +import { tasks, type Task } from "@/db/schema/tasks" +import { zodResolver } from "@hookform/resolvers/zod" +import { Loader } from "lucide-react" +import { useForm } from "react-hook-form" +import { toast } from "sonner" + +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Textarea } from "@/components/ui/textarea" + +import { modifiTask } from "@/lib//tasks/service" +import { updateTaskSchema, type UpdateTaskSchema } from "@/lib/tasks/validations" +import { Item } from "@/db/schema/items" +import { updateItemSchema, UpdateItemSchema } from "../validations" +import { modifyItem } from "../service" +import { Input } from "@/components/ui/input" + +interface UpdateItemSheetProps + extends React.ComponentPropsWithRef<typeof Sheet> { + item: Item | null +} + +export function UpdateItemSheet({ item, ...props }: UpdateItemSheetProps) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + + console.log(item) + const form = useForm<UpdateItemSchema>({ + resolver: zodResolver(updateItemSchema), + defaultValues: { + itemCode: item?.itemCode ?? "", + itemName: item?.itemName ?? "", + description: item?.description ?? "", + + }, + }) + + + React.useEffect(() => { + if (item) { + form.reset({ + itemCode: item.itemCode ?? "", + itemName: item.itemName ?? "", + description: item.description ?? "", + }); + } + }, [item, form]); + + function onSubmit(input: UpdateItemSchema) { + startUpdateTransition(async () => { + if (!item) return + + const { error } = await modifyItem({ + id: item.id, + ...input, + }) + + if (error) { + toast.error(error) + return + } + + form.reset() + props.onOpenChange?.(false) + toast.success("Item updated") + }) + } + + return ( + <Sheet {...props}> + <SheetContent className="flex flex-col gap-6 sm:max-w-md"> + <SheetHeader className="text-left"> + <SheetTitle>Update item</SheetTitle> + <SheetDescription> + Update the item details and save the changes + </SheetDescription> + </SheetHeader> + <Form {...form}> + <form + onSubmit={form.handleSubmit(onSubmit)} + className="flex flex-col gap-4" + > + <FormField + control={form.control} + name="itemCode" + render={({ field }) => ( + <FormItem> + <FormLabel>Item Code</FormLabel> + <FormControl> + <Input placeholder="e.g." {...field} /> + + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="itemName" + render={({ field }) => ( + <FormItem> + <FormLabel>Item Name</FormLabel> + <FormControl> + <Input + placeholder="e.g." + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="description" + render={({ field }) => ( + <FormItem> + <FormLabel>Description</FormLabel> + <FormControl> + <Textarea + placeholder="e.g." + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <SheetFooter className="gap-2 pt-2 sm:space-x-0"> + <SheetClose asChild> + <Button type="button" variant="outline"> + Cancel + </Button> + </SheetClose> + <Button disabled={isUpdatePending}> + {isUpdatePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + Save + </Button> + </SheetFooter> + </form> + </Form> + </SheetContent> + </Sheet> + ) +} diff --git a/lib/items/validations.ts b/lib/items/validations.ts new file mode 100644 index 00000000..d299959c --- /dev/null +++ b/lib/items/validations.ts @@ -0,0 +1,47 @@ +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, +} from "nuqs/server" +import * as z from "zod" + +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" +import { Item } from "@/db/schema/items"; + +export const searchParamsCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault( + [] + ), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser<Item>().withDefault([ + { id: "createdAt", desc: true }, + ]), + itemCode: parseAsString.withDefault(""), + itemName: parseAsString.withDefault(""), + description: parseAsString.withDefault(""), + + // advanced filter + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), + +}) + +export const createItemSchema = z.object({ + itemCode: z.string(), + itemName: z.string(), + description: z.string(), +}) + +export const updateItemSchema = z.object({ + itemCode: z.string().optional(), + itemName: z.string().optional(), + description: z.string().optional(), +}) + +export type GetItemsSchema = Awaited<ReturnType<typeof searchParamsCache.parse>> +export type CreateItemSchema = z.infer<typeof createItemSchema> +export type UpdateItemSchema = z.infer<typeof updateItemSchema> diff --git a/lib/logger.ts b/lib/logger.ts new file mode 100644 index 00000000..f49f52c2 --- /dev/null +++ b/lib/logger.ts @@ -0,0 +1,26 @@ +// lib/logger.ts +import pino from 'pino'; +import fs from 'fs'; + +// 로그 디렉토리 생성 +const logDirectory = './logs'; +if (!fs.existsSync(logDirectory)) { + fs.mkdirSync(logDirectory); +} + +// 파일 스트림 설정 +const fileStream = fs.createWriteStream('./logs/app.log', { flags: 'a' }); + +// Pino의 multistream을 사용한 멀티 스트림 설정 +const streams: pino.StreamEntry[] = [ + { level: 'info', stream: fileStream }, // 파일에 로그 기록 + process.env.NODE_ENV !== 'production' ? { level: 'debug', stream: pino.destination(1) } : null, // 콘솔에 로그 기록 (개발 환경) +].filter(Boolean) as pino.StreamEntry[]; + +// Pino 로거 생성 +const logger = pino({ + timestamp: pino.stdTimeFunctions.isoTime, + level: process.env.LOG_LEVEL || 'info', +}, pino.multistream(streams)); + +export default logger;
\ No newline at end of file diff --git a/lib/mail/mailer.ts b/lib/mail/mailer.ts new file mode 100644 index 00000000..e0a90f1e --- /dev/null +++ b/lib/mail/mailer.ts @@ -0,0 +1,31 @@ +import nodemailer from 'nodemailer'; +import handlebars from 'handlebars'; +import fs from 'fs'; +import path from 'path'; +import i18next from 'i18next'; + +// Nodemailer Transporter 생성 +const transporter = nodemailer.createTransport({ + host: process.env.Email_Host, + port: 465, + secure: true, + auth: { + user: process.env.Email_User_Name, + pass: process.env.Email_Password, + }, +}); + +// Handlebars 템플릿 로더 함수 +function loadTemplate(templateName: string, data: Record<string, any>) { + const templatePath = path.join(process.cwd(), 'lib', 'mail', 'templates', `${templateName}.hbs`); + const source = fs.readFileSync(templatePath, 'utf8'); + const template = handlebars.compile(source); + return template(data); +} + +handlebars.registerHelper('t', function(key: string, options: any) { + // options.hash에는 Handlebars에서 넘긴 named parameter들(location=location 등)이 들어있음 + return i18next.t(key, options.hash || {}); + }); + +export { transporter, loadTemplate };
\ No newline at end of file diff --git a/lib/mail/sendEmail.ts b/lib/mail/sendEmail.ts new file mode 100644 index 00000000..48cc1fbc --- /dev/null +++ b/lib/mail/sendEmail.ts @@ -0,0 +1,36 @@ +import { useTranslation } from '@/i18n'; +import { transporter, loadTemplate } from './mailer'; +import handlebars from 'handlebars'; + +interface SendEmailOptions { + to: string; + subject: string; + template: string; // 템플릿 파일명(확장자 제외) + context: Record<string, any>; // 템플릿에 주입할 데이터 + attachments?: { // NodeMailer "Attachment" 타입 + filename?: string + path?: string + content?: Buffer | string + // ... + }[] +} + +export async function sendEmail({ to, subject, template, context, attachments = []}: SendEmailOptions) { + const { t, i18n } = await useTranslation(context.language ?? "en", "translation"); + + handlebars.registerHelper("t", function (key: string, options: any) { + // 여기서 i18n은 로컬 인스턴스 + return i18n.t(key, options.hash || {}); + }); + + const html = loadTemplate(template, context); + + await transporter.sendMail({ + from: 'EVCP" <dujin.kim@dtsolution.co.kr>', + to, + subject, + html, + attachments + }); +} + diff --git a/lib/mail/templates/admin-created.hbs b/lib/mail/templates/admin-created.hbs new file mode 100644 index 00000000..7be7f15d --- /dev/null +++ b/lib/mail/templates/admin-created.hbs @@ -0,0 +1,78 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8" /> + <title>{{t "adminCreated.title" lng=language}}</title> + <style> + /* 간단한 스타일 예시 */ + body { + font-family: Arial, sans-serif; + margin: 0; + padding: 16px; + background-color: #f5f5f5; + } + .container { + max-width: 600px; + margin: 0 auto; + background-color: #ffffff; + padding: 24px; + border-radius: 8px; + } + h1 { + font-size: 20px; + margin-bottom: 16px; + } + p { + font-size: 14px; + line-height: 1.6; + } + .btn { + display: inline-block; + margin-top: 16px; + padding: 12px 24px; + background-color: #1D4ED8; + color: #ffffff !important; + text-decoration: none; + border-radius: 4px; + } + .footer { + margin-top: 24px; + font-size: 12px; + color: #888888; + } + </style> + </head> + <body> + <div class="container"> + <!-- 상단 로고/타이틀 영역 --> + <div style="text-align: center;"> + <!-- 필요 시 로고 이미지 --> + <!-- <img src="https://your-logo-url.com/logo.png" alt="EVCP" width="120" /> --> + </div> + + <h1>{{t "adminCreated.title" lng=language}}</h1> + + <p> + {{t "adminCreated.greeting" lng=language}}, <strong>{{name}}</strong>. + </p> + + <p> + {{t "adminCreated.body1" lng=language}} + </p> + + <p> + <a class="btn" href="{{loginUrl}}" target="_blank">{{t "adminCreated.loginCTA" lng=language}}</a> + </p> + + <p> + {{t "adminCreated.supportMsg" lng=language}} + </p> + + <div class="footer"> + <p> + {{t "adminCreated.footerDisclaimer" lng=language}} + </p> + </div> + </div> + </body> +</html>
\ No newline at end of file diff --git a/lib/mail/templates/admin-email-changed.hbs b/lib/mail/templates/admin-email-changed.hbs new file mode 100644 index 00000000..7b8ca473 --- /dev/null +++ b/lib/mail/templates/admin-email-changed.hbs @@ -0,0 +1,90 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8" /> + <title>{{t "adminEmailChanged.title" lng=language}}</title> + <style> + /* 간단한 스타일 예시 */ + body { + font-family: Arial, sans-serif; + margin: 0; + padding: 16px; + background-color: #f5f5f5; + } + .container { + max-width: 600px; + margin: 0 auto; + background-color: #ffffff; + padding: 24px; + border-radius: 8px; + } + h1 { + font-size: 20px; + margin-bottom: 16px; + } + p { + font-size: 14px; + line-height: 1.6; + } + .btn { + display: inline-block; + margin-top: 16px; + padding: 12px 24px; + background-color: #1D4ED8; + color: #ffffff !important; + text-decoration: none; + border-radius: 4px; + } + .footer { + margin-top: 24px; + font-size: 12px; + color: #888888; + } + </style> + </head> + <body> + <div class="container"> + <!-- 상단 로고/타이틀 영역 --> + <div style="text-align: center;"> + <!-- 필요 시 로고 이미지 --> + <!-- <img src="https://your-logo-url.com/logo.png" alt="EVCP" width="120" /> --> + </div> + + <!-- 메일 제목 --> + <h1>{{t "adminEmailChanged.title" lng=language}}</h1> + + <!-- 인사말 --> + <p> + {{t "adminEmailChanged.greeting" lng=language}}, <strong>{{name}}</strong>. + </p> + + <!-- 이전 이메일 / 새 이메일 안내 --> + <p> + {{t "adminEmailChanged.body.intro" lng=language}} + </p> + <p> + <strong>{{t "adminEmailChanged.body.oldEmail" lng=language}}:</strong> {{oldEmail}}<br /> + <strong>{{t "adminEmailChanged.body.newEmail" lng=language}}:</strong> {{newEmail}} + </p> + + <!-- 버튼(로그인 / 대시보드 등) --> + <p> + <a class="btn" href="{{loginUrl}}" target="_blank"> + {{t "adminEmailChanged.loginCTA" lng=language}} + </a> + </p> + + <!-- 도움 요청 문구 --> + <p> + {{t "adminEmailChanged.supportMsg" lng=language}} + </p> + + <!-- 푸터 --> + <div class="footer"> + <p> + {{t "adminEmailChanged.footerDisclaimer" lng=language}} + </p> + </div> + </div> + </body> +</html>
\ No newline at end of file diff --git a/lib/mail/templates/otp.hbs b/lib/mail/templates/otp.hbs new file mode 100644 index 00000000..adeda416 --- /dev/null +++ b/lib/mail/templates/otp.hbs @@ -0,0 +1,77 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0"/> + <title>{{subject}}</title> + <style> + body { + font-family: Arial, sans-serif; + background: #f9fafb; + color: #111827; + padding: 20px; + } + .container { + max-width: 480px; + margin: 0 auto; + background: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 6px; + padding: 24px; + } + h1 { + font-size: 20px; + margin-bottom: 8px; + color: #111827; + } + p { + line-height: 1.5; + margin-bottom: 16px; + } + .code { + display: inline-block; + font-size: 24px; + font-weight: bold; + letter-spacing: 2px; + margin: 12px 0; + background: #f3f4f6; + padding: 8px 16px; + border-radius: 4px; + } + a { + color: #3b82f6; + text-decoration: none; + } + .footer { + font-size: 12px; + color: #6b7280; + margin-top: 24px; + } + </style> + </head> + <body> + <div class="container"> + <h1>{{t "verifyYourEmailTitle"}}</h1> + <p>{{t "greeting"}}, {{name}}</p> + + <p> + {{t "receivedSignInAttempt" location=location}} + </p> + + <p> + {{t "enterCodeInstruction"}} + </p> + + <p class="code">{{otp}}</p> + + <p> + <a href="{{verificationUrl}}">{{verificationUrl}}</a> + </p> + + + <div class="footer"> + {{t "securityWarning"}} + </div> + </div> + </body> +</html>
\ No newline at end of file diff --git a/lib/mail/templates/rfq-invite.hbs b/lib/mail/templates/rfq-invite.hbs new file mode 100644 index 00000000..25bd96eb --- /dev/null +++ b/lib/mail/templates/rfq-invite.hbs @@ -0,0 +1,116 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8" /> + <title>{{t "rfqInvite.title" lng=language}} #{{rfqCode}}</title> + <style> + /* 간단한 스타일 예시 */ + body { + font-family: Arial, sans-serif; + margin: 0; + padding: 16px; + background-color: #f5f5f5; + } + .container { + max-width: 600px; + margin: 0 auto; + background-color: #ffffff; + padding: 24px; + border-radius: 8px; + } + h1 { + font-size: 20px; + margin-bottom: 16px; + } + p { + font-size: 14px; + line-height: 1.6; + } + ul { + margin-left: 20px; + } + li { + font-size: 14px; + line-height: 1.6; + } + .btn { + display: inline-block; + margin-top: 16px; + padding: 12px 24px; + background-color: #1D4ED8; + color: #ffffff !important; + text-decoration: none; + border-radius: 4px; + } + .footer { + margin-top: 24px; + font-size: 12px; + color: #888888; + } + </style> + </head> + <body> + <div class="container"> + <!-- 상단 로고/타이틀 영역 --> + <div style="text-align: center;"> + <!-- 필요 시 로고 이미지 --> + <!-- <img src="https://your-logo-url.com/logo.png" alt="EVCP" width="120" /> --> + </div> + + <!-- 메인 타이틀: RFQ 초대 --> + <h1> + {{t "rfqInvite.heading" lng=language}} + #{{rfqCode}} + </h1> + + <!-- 벤더에게 인사말 --> + <p> + {{t "rfqInvite.greeting" lng=language}}, <strong>Vendor #{{vendorId}}</strong>. + </p> + + <!-- 프로젝트/RFQ 정보 --> + <p> + {{t "rfqInvite.bodyIntro" lng=language}} + <br /> + <strong>{{t "rfqInvite.projectName" lng=language}}:</strong> {{projectName}}<br /> + <strong>{{t "rfqInvite.projectCode" lng=language}}:</strong> {{projectCode}}<br /> + <strong>{{t "rfqInvite.dueDate" lng=language}}:</strong> {{dueDate}}<br /> + <strong>{{t "rfqInvite.description" lng=language}}:</strong> {{description}} + </p> + + <!-- 아이템 목록 --> + <p> + {{t "rfqInvite.itemListTitle" lng=language}} + </p> + <ul> + {{#each items}} + <li> + <strong>{{this.itemCode}}</strong> + ({{this.quantity}} {{this.uom}}) + - {{this.description}} + </li> + {{/each}} + </ul> + + <!-- 로그인/접속 안내 --> + <p> + {{t "rfqInvite.moreDetail" lng=language}} + </p> + <a class="btn" href="{{loginUrl}}" target="_blank"> + {{t "rfqInvite.viewButton" lng=language}} + </a> + + <!-- 기타 안내 문구 --> + <p> + {{t "rfqInvite.supportMsg" lng=language}} + </p> + + <!-- 푸터 --> + <div class="footer"> + <p> + {{t "rfqInvite.footerDisclaimer" lng=language}} + </p> + </div> + </div> + </body> +</html>
\ No newline at end of file diff --git a/lib/mail/templates/vendor-active.hbs b/lib/mail/templates/vendor-active.hbs new file mode 100644 index 00000000..6458e2fb --- /dev/null +++ b/lib/mail/templates/vendor-active.hbs @@ -0,0 +1,51 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <title>벤더 등록이 완료되었습니다</title> + <style> + body { font-family: 'Malgun Gothic', sans-serif; line-height: 1.6; } + .container { max-width: 600px; margin: 0 auto; padding: 20px; } + .header { background-color: #f5f5f5; padding: 10px; text-align: center; } + .content { padding: 20px 0; } + .vendor-code { font-size: 18px; font-weight: bold; background-color: #f0f0f0; + padding: 10px; margin: 15px 0; text-align: center; } + .button { display: inline-block; background-color: #28a745; color: white; + padding: 10px 20px; text-decoration: none; border-radius: 4px; } + .footer { margin-top: 20px; font-size: 12px; color: #777; } + </style> +</head> +<body> + <div class="container"> + <div class="header"> + <h2>벤더 등록이 완료되었습니다</h2> + </div> + + <div class="content"> + <p>{{vendorName}} 귀하,</p> + + <p>축하합니다! 귀사의 벤더 등록이 완료되었으며 벤더 정보가 당사 시스템에 성공적으로 등록되었습니다.</p> + + <p>귀사의 벤더 코드는 다음과 같습니다:</p> + <div class="vendor-code">{{vendorCode}}</div> + + <p>향후 모든 의사소통 및 거래 시 이 벤더 코드를 사용해 주십시오. 이제 벤더 포털에 접속하여 계정 관리, 발주서 확인 및 인보이스 제출을 할 수 있습니다.</p> + + <p style="text-align: center; margin: 25px 0;"> + <a href="{{portalUrl}}" class="button">벤더 포털 접속</a> + </p> + + <p>벤더 계정에 관한 질문이나 도움이 필요하시면 당사 벤더 관리팀에 문의해 주십시오.</p> + + <p>파트너십에 감사드립니다.</p> + + <p>감사합니다.<br> + eVCP 팀</p> + </div> + + <div class="footer"> + <p>이 메시지는 자동으로 발송되었습니다. 이 이메일에 회신하지 마십시오.</p> + </div> + </div> +</body> +</html>
\ No newline at end of file diff --git a/lib/mail/templates/vendor-pq-comment.hbs b/lib/mail/templates/vendor-pq-comment.hbs new file mode 100644 index 00000000..b60deedc --- /dev/null +++ b/lib/mail/templates/vendor-pq-comment.hbs @@ -0,0 +1,128 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>PQ Review Comments</title> + <style> + body { + font-family: Arial, sans-serif; + line-height: 1.6; + color: #333; + margin: 0; + padding: 0; + } + .container { + max-width: 600px; + margin: 0 auto; + padding: 20px; + } + .header { + text-align: center; + padding: 20px 0; + border-bottom: 1px solid #eee; + } + .content { + padding: 20px 0; + } + .footer { + text-align: center; + padding: 20px 0; + font-size: 12px; + color: #999; + border-top: 1px solid #eee; + } + .btn { + display: inline-block; + padding: 10px 20px; + font-size: 16px; + color: #fff; + background-color: #0071bc; + text-decoration: none; + border-radius: 4px; + margin: 20px 0; + } + .comment-section { + margin: 20px 0; + padding: 15px; + background-color: #f9f9f9; + border-left: 4px solid #0071bc; + } + .comment-item { + margin-bottom: 15px; + padding-bottom: 15px; + border-bottom: 1px solid #eee; + } + .comment-item:last-child { + border-bottom: none; + } + .comment-code { + font-weight: bold; + color: #0071bc; + display: inline-block; + min-width: 60px; + } + .comment-title { + font-weight: bold; + color: #333; + } + .important { + color: #d14; + font-weight: bold; + } + </style> +</head> +<body> + <div class="container"> + <div class="header"> + <h1>PQ Review Comments</h1> + </div> + + <div class="content"> + <p>Dear {{name}} ({{vendorCode}}),</p> + + <p>Thank you for submitting your PQ information. Our review team has completed the initial review and has requested some changes or additional information.</p> + + <p><span class="important">Action Required:</span> Please log in to your account and update your PQ submission based on the comments below.</p> + + {{#if hasGeneralComment}} + <div class="comment-section"> + <h3>General Comments:</h3> + <p>{{generalComment}}</p> + </div> + {{/if}} + + <div class="comment-section"> + <h3>Specific Item Comments ({{commentCount}}):</h3> + {{#each comments}} + <div class="comment-item"> + <div> + <span class="comment-code">{{code}}</span> + <span class="comment-title">{{checkPoint}}</span> + </div> + <p>{{text}}</p> + </div> + {{/each}} + </div> + + <p>Please review these comments and make the necessary updates to your PQ submission. Once you have made the requested changes, you can resubmit your PQ for further review.</p> + + <div style="text-align: center;"> + <a href="{{loginUrl}}" class="btn">Log in to update your PQ</a> + </div> + + <p>If you have any questions or need assistance, please contact our support team.</p> + + <p>Thank you for your cooperation.</p> + + <p>Best regards,<br> + PQ Review Team</p> + </div> + + <div class="footer"> + <p>This is an automated email. Please do not reply to this message.</p> + <p>© {{currentYear}} Your Company Name. All rights reserved.</p> + </div> + </div> +</body> +</html>
\ No newline at end of file diff --git a/lib/mail/templates/vendor-pq-status.hbs b/lib/mail/templates/vendor-pq-status.hbs new file mode 100644 index 00000000..541a6137 --- /dev/null +++ b/lib/mail/templates/vendor-pq-status.hbs @@ -0,0 +1,48 @@ +<!-- file: templates/vendor-pq-status.hbs --> + +<html> + <body style="font-family: sans-serif; margin: 0; padding: 0;"> + <table width="100%" cellspacing="0" cellpadding="20" style="background-color: #f7f7f7;"> + <tr> + <td> + <table width="600" cellspacing="0" cellpadding="20" style="background-color: #ffffff; margin: 0 auto;"> + <tr> + <td style="text-align: center;"> + <h1 style="margin-bottom: 0.5rem;">Vendor PQ Status Update</h1> + </td> + </tr> + <tr> + <td> + <p>Hello {{name}},</p> + <p> + Your vendor status has been updated to + <strong>{{status}}</strong>. + </p> + <p> + You can log in to see details and take further action: + <br /> + <a href="{{loginUrl}}" style="color: #007bff; text-decoration: underline;"> + Go to Portal + </a> + </p> + <p> + If you have any questions, feel free to contact us. + </p> + <p>Thank you,<br/> + The PQ Team + </p> + </td> + </tr> + <tr> + <td style="text-align: center; border-top: 1px solid #eee;"> + <small style="color: #999;"> + © 2023 MyCompany + </small> + </td> + </tr> + </table> + </td> + </tr> + </table> + </body> +</html>
\ No newline at end of file diff --git a/lib/parsers.ts b/lib/parsers.ts new file mode 100644 index 00000000..20f3107b --- /dev/null +++ b/lib/parsers.ts @@ -0,0 +1,94 @@ +import type { ExtendedSortingState, Filter } from "@/types/table" +import { type Row } from "@tanstack/react-table" +import { createParser } from "nuqs/server" +import { z } from "zod" + +import { dataTableConfig } from "@/config/data-table" + +export const sortingItemSchema = z.object({ + id: z.string(), + desc: z.boolean(), +}) + +/** + * Creates a parser for TanStack Table sorting state. + * @param originalRow The original row data to validate sorting keys against. + * @returns A parser for TanStack Table sorting state. + */ +export const getSortingStateParser = <TData>( + originalRow?: Row<TData>["original"] +) => { + const validKeys = originalRow ? new Set(Object.keys(originalRow)) : null + + return createParser<ExtendedSortingState<TData>>({ + parse: (value) => { + try { + const parsed = JSON.parse(value) + const result = z.array(sortingItemSchema).safeParse(parsed) + + if (!result.success) return null + + if (validKeys && result.data.some((item) => !validKeys.has(item.id))) { + return null + } + + return result.data as ExtendedSortingState<TData> + } catch { + return null + } + }, + serialize: (value) => JSON.stringify(value), + eq: (a, b) => + a.length === b.length && + a.every( + (item, index) => + item.id === b[index]?.id && item.desc === b[index]?.desc + ), + }) +} + +export const filterSchema = z.object({ + id: z.string(), + value: z.union([z.string(), z.array(z.string())]), + type: z.enum(dataTableConfig.columnTypes), + operator: z.enum(dataTableConfig.globalOperators), + rowId: z.string(), +}) + +/** + * Create a parser for data table filters. + * @param originalRow The original row data to create the parser for. + * @returns A parser for data table filters state. + */ +export const getFiltersStateParser = <T>(originalRow?: Row<T>["original"]) => { + const validKeys = originalRow ? new Set(Object.keys(originalRow)) : null + + return createParser<Filter<T>[]>({ + parse: (value) => { + try { + const parsed = JSON.parse(value) + const result = z.array(filterSchema).safeParse(parsed) + + if (!result.success) return null + + if (validKeys && result.data.some((item) => !validKeys.has(item.id))) { + return null + } + + return result.data as Filter<T>[] + } catch { + return null + } + }, + serialize: (value) => JSON.stringify(value), + eq: (a, b) => + a.length === b.length && + a.every( + (filter, index) => + filter.id === b[index]?.id && + filter.value === b[index]?.value && + filter.type === b[index]?.type && + filter.operator === b[index]?.operator + ), + }) +} diff --git a/lib/po/repository.ts b/lib/po/repository.ts new file mode 100644 index 00000000..78d90ba7 --- /dev/null +++ b/lib/po/repository.ts @@ -0,0 +1,44 @@ +import db from "@/db/db"; +import { contractsDetailView } from "@/db/schema/contract"; +import { + eq, + inArray, + not, + asc, + desc, + and, + ilike, + gte, + lte, + count, + gt, +} from "drizzle-orm"; +import { PgTransaction } from "drizzle-orm/pg-core"; + +export async function selectPos( + tx: PgTransaction<any, any, any>, + params: { + where?: any; // drizzle-orm의 조건식 (and, eq...) 등 + orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[]; + offset?: number; + limit?: number; + } +) { + const { where, orderBy, offset = 0, limit = 10 } = params; + + return tx + .select() + .from(contractsDetailView) + .where(where) + .orderBy(...(orderBy ?? [])) + .offset(offset) + .limit(limit); +} +/** 총 개수 count */ +export async function countPos( + tx: PgTransaction<any, any, any>, + where?: any +) { + const res = await tx.select({ count: count() }).from(contractsDetailView).where(where); + return res[0]?.count ?? 0; +} diff --git a/lib/po/service.ts b/lib/po/service.ts new file mode 100644 index 00000000..dc398201 --- /dev/null +++ b/lib/po/service.ts @@ -0,0 +1,431 @@ +"use server"; + +import { headers } from "next/headers"; +import db from "@/db/db"; +import { GetPOSchema } from "./validations"; +import { unstable_cache } from "@/lib/unstable-cache"; +import { filterColumns } from "@/lib/filter-columns"; +import { + asc, + desc, + ilike, + inArray, + and, + gte, + lte, + not, + or, + eq, + count, +} from "drizzle-orm"; +import { countPos, selectPos } from "./repository"; + +import { + contractEnvelopes, + contractsDetailView, + contractSigners, + contracts, +} from "@/db/schema/contract"; +import { vendors, vendorContacts } from "@/db/schema/vendors"; +import { revalidatePath } from "next/cache"; +import * as z from "zod"; +import { POContent } from "@/lib/docuSign/types"; + +/** + * PQ 목록 조회 + */ +export async function getPOs(input: GetPOSchema) { + return unstable_cache( + async () => { + try { + const offset = (input.page - 1) * input.perPage; + + // 1. Try a simple query first to make sure the view works at all + try { + const testQuery = await db + .select({ count: count() }) + .from(contractsDetailView); + console.log("Test query result:", testQuery); + } catch (testErr) { + console.error("Test query failed:", testErr); + } + + // 2. Build where clause with more careful handling + let advancedWhere; + try { + advancedWhere = filterColumns({ + table: contractsDetailView, + filters: input.filters, + joinOperator: input.joinOperator, + }); + console.log("Advanced where clause built successfully"); + } catch (whereErr) { + console.error("Error building advanced where:", whereErr); + advancedWhere = undefined; + } + + let globalWhere; + if (input.search) { + try { + const s = `%${input.search}%`; + globalWhere = or( + ilike(contractsDetailView.contractNo, s), + ilike(contractsDetailView.contractName, s) + ); + console.log("Global where clause built successfully"); + } catch (searchErr) { + console.error("Error building search where:", searchErr); + globalWhere = undefined; + } + } + + // 3. Combine where clauses safely + let finalWhere; + if (advancedWhere && globalWhere) { + finalWhere = and(advancedWhere, globalWhere); + } else { + finalWhere = advancedWhere || globalWhere; + } + + // 4. Build order by + let orderBy; + try { + orderBy = + input.sort.length > 0 + ? input.sort.map((item) => + item.desc + ? desc(contractsDetailView[item.id]) + : asc(contractsDetailView[item.id]) + ) + : [asc(contractsDetailView.createdAt)]; + } catch (orderErr) { + console.error("Error building order by:", orderErr); + orderBy = [asc(contractsDetailView.createdAt)]; + } + + // 5. Execute queries with proper error handling + let data = []; + let total = 0; + + try { + // Try without transaction first for better error visibility + const queryBuilder = db.select().from(contractsDetailView); + + // Add where clause if it exists + if (finalWhere) { + queryBuilder.where(finalWhere); + } + + // Add ordering + queryBuilder.orderBy(...orderBy); + + // Add pagination + queryBuilder.offset(offset).limit(input.perPage); + + // Execute query + data = await queryBuilder; + + // Get total count + const countBuilder = db + .select({ count: count() }) + .from(contractsDetailView); + + if (finalWhere) { + countBuilder.where(finalWhere); + } + + const countResult = await countBuilder; + total = countResult[0]?.count || 0; + } catch (queryErr) { + console.error("Query execution failed:", queryErr); + throw queryErr; // Rethrow to be caught by the outer try/catch + } + + const pageCount = Math.ceil(total / input.perPage); + + return { data, pageCount }; + } catch (err) { + // More detailed error logging + console.error("Error in getPOs:", err); + if (err instanceof Error) { + console.error("Error message:", err.message); + console.error("Error stack:", err.stack); + } + return { data: [], pageCount: 0 }; + } + }, + [JSON.stringify(input)], + { + revalidate: 3600, + tags: [`po`], + } + )(); +} + +// Schema for a single signer +const signerSchema = z.object({ + signerEmail: z.string().email(), + signerName: z.string().min(1), + signerPosition: z.string(), + signerType: z.enum(["REQUESTER", "VENDOR"]), + vendorContactId: z.number().optional(), +}); + +// Schema for the entire request +const signatureRequestSchema = z.object({ + contractId: z.number(), + signers: z.array(signerSchema).min(1, "At least one signer is required"), +}); + +/** + * Server action to request electronic signatures for a contract from multiple parties + */ +export async function requestSignatures( + input: z.infer<typeof signatureRequestSchema> +): Promise<{ success: boolean; message: string }> { + try { + // Validate the input + const validatedData = signatureRequestSchema.parse(input); + + const headersList = await headers(); + const host = headersList.get("host"); + const proto = headersList.get("x-forwarded-proto") || "http"; // 기본값은 http + const origin = `${proto}://${host}`; + + // Use a transaction to ensure data consistency + return await db.transaction(async (tx) => { + // Get contract details using standard select + const [contract] = await tx + .select() + .from(contracts) + .where(eq(contracts.id, validatedData.contractId)) + .limit(1); + + if (!contract) { + throw new Error( + `Contract with ID ${validatedData.contractId} not found` + ); + } + + // Generate unique envelope ID + // const envelopeId = `env-${Date.now()}-${Math.floor( + // Math.random() * 1000 + // )}`; + + // Get contract number or fallback + const contractNo = + contract.contractNo || `contract-${validatedData.contractId}`; + + const signer = validatedData.signers.find( + (c) => c.signerType === "REQUESTER" + ); + + const vendor = validatedData.signers.find( + (c) => c.signerType === "VENDOR" + ); + + if (!vendor || !signer) { + return { + success: true, + message: `협력업체 서명자를 확인할 수 없습니다.`, + }; + } + + const { vendorContactId } = vendor; + + if (!vendorContactId) { + return { + success: true, + message: `계약 번호를 확인할 수 없습니다.`, + }; + } + + const [vendorInfoData] = await tx + .select({ + vendorContract: vendorContacts, + vendorInfo: vendors, + }) + .from(vendorContacts) + .leftJoin(vendors, eq(vendorContacts.vendorId, vendors.id)) + .where(eq(vendorContacts.id, vendorContactId)) + .limit(1); + + const { vendorContract, vendorInfo } = vendorInfoData; + + const docuSignTempId = "73b04617-477c-4ec8-8a32-c8da701f6b0c"; + + const { totalAmount = "0", tax = "0" } = contract; + + const totalAmountNum = Number(totalAmount); + const taxNum = Number(tax); + const taxRate = ((taxNum / totalAmountNum) * 100).toFixed(2); + + const contractInfo: POContent = [ + { tabLabel: "po_no", value: contractNo }, + { tabLabel: "vendor_name", value: vendorInfo?.vendorName ?? "" }, + { tabLabel: "po_date", value: contract?.startDate ?? "" }, + { tabLabel: "project_name", value: contract.contractName }, + { tabLabel: "vendor_location", value: vendorInfo?.address ?? "" }, + { tabLabel: "shi_email", value: signer.signerEmail }, + { tabLabel: "vendor_email", value: vendorContract.contactEmail }, + { tabLabel: "po_desc", value: contract.contractName }, + { tabLabel: "qty", value: "1" }, + { tabLabel: "unit_price", value: totalAmountNum.toLocaleString() }, + { tabLabel: "total", value: totalAmountNum.toLocaleString() }, + { + tabLabel: "grand_total_amount", + value: totalAmountNum.toLocaleString(), + }, + { tabLabel: "tax_rate", value: taxRate }, + { tabLabel: "tax_total", value: taxNum.toLocaleString() }, + { + tabLabel: "payment_amount", + value: (totalAmountNum + taxNum).toLocaleString(), + }, + { + tabLabel: "remark", + value: `결제 조건: ${contract.paymentTerms} +납품 조건: ${contract.deliveryTerms} +납품 기한: ${contract.deliveryDate} +납품 장소: ${contract.deliveryLocation} +계약 종료일/유효 기간: ${contract.endDate} +Remarks:${contract.remarks}`, + }, + ]; + + const sendDocuSign = await fetch(`${origin}/api/po/sendDocuSign`, { + method: "POST", + headers: { + "Content-Type": "application/json", // ✅ 이거 꼭 있어야 함! + }, + body: JSON.stringify({ + docuSignTempId, + contractInfo, + contractorInfo: { + email: "dts@dtsolution.co.kr", + name: "삼성중공업", + roleName: "shi", + }, + subcontractorinfo: { + email: vendorContract.contactEmail, + name: vendorInfo?.vendorName, + roleName: "vendor", + }, + ccInfo: [ + // { + // email: "kiman.kim@dtsolution.io", + // name: "김기만", + // roleName: "cc", + // }, + ], + }), + }).then((data) => data.json()); + + const { success: sendDocuSignResult, envelopeId } = sendDocuSign; + + if (!sendDocuSignResult) { + return { + success: false, + message: "DocuSign 전자 서명 발송에 실패하였습니다.", + }; + } + + // Create a single envelope for all signers + const [newEnvelope] = await tx + .insert(contractEnvelopes) + .values({ + contractId: validatedData.contractId, + envelopeId: envelopeId, + envelopeStatus: "sent", + fileName: `${contractNo}-signature.pdf`, // Required field + filePath: `/contracts/${validatedData.contractId}/signatures`, // Required field + // Add any other required fields based on your schema + }) + .returning(); + + // // Check for duplicate emails + const signerEmails = new Set(); + for (const signer of validatedData.signers) { + if (signerEmails.has(signer.signerEmail)) { + throw new Error(`Duplicate signer email: ${signer.signerEmail}`); + } + signerEmails.add(signer.signerEmail); + } + + // Create signer records for each signer + for (const signer of validatedData.signers) { + await tx.insert(contractSigners).values({ + envelopeId: newEnvelope.id, + signerEmail: signer.signerEmail, + signerName: signer.signerName, + signerPosition: signer.signerPosition, + signerStatus: "sent", + signerType: signer.signerType, + // Only include vendorContactId if it's provided and the signer is a vendor + ...(signer.vendorContactId && signer.signerType === "VENDOR" + ? { vendorContactId: signer.vendorContactId } + : {}), + }); + } + + // Update contract status to indicate pending signatures + await tx + .update(contracts) + .set({ status: "PENDING_SIGNATURE" }) + .where(eq(contracts.id, validatedData.contractId)); + + // In a real implementation, you would send the envelope to DocuSign or similar service + // For example: + // const docusignResult = await docusignClient.createEnvelope({ + // recipients: validatedData.signers.map(signer => ({ + // email: signer.signerEmail, + // name: signer.signerName, + // recipientType: signer.signerType === "REQUESTER" ? "signer" : "cc", + // routingOrder: signer.signerType === "REQUESTER" ? 1 : 2, + // })), + // documentId: `contract-${validatedData.contractId}`, + // // other DocuSign-specific parameters + // }); + + // Revalidate the path to refresh the data + revalidatePath("/po"); + + // Return success response + return { + success: true, + message: `Signature requests sent to ${validatedData.signers.length} recipient(s)`, + }; + }); + } catch (error) { + console.error("Error requesting electronic signatures:", error); + return { + success: false, + message: + error instanceof Error + ? error.message + : "Failed to send signature requests", + }; + } +} + +export async function getVendorContacts(vendorId: number) { + try { + const contacts = await db + .select({ + id: vendorContacts.id, + contactName: vendorContacts.contactName, + contactEmail: vendorContacts.contactEmail, + contactPosition: vendorContacts.contactPosition, + contactPhone: vendorContacts.contactPhone, + isPrimary: vendorContacts.isPrimary, + }) + .from(vendorContacts) + .where(eq(vendorContacts.vendorId, vendorId)) + .orderBy(vendorContacts.isPrimary, vendorContacts.contactName); + + return contacts; + } catch (error) { + console.error("Error fetching vendor contacts:", error); + throw new Error("Failed to fetch vendor contacts"); + } +} diff --git a/lib/po/service_r1.ts b/lib/po/service_r1.ts new file mode 100644 index 00000000..64af73c4 --- /dev/null +++ b/lib/po/service_r1.ts @@ -0,0 +1,282 @@ +"use server" + +import db from "@/db/db" +import { GetPOSchema } from "./validations" +import { unstable_cache } from "@/lib/unstable-cache"; +import { filterColumns } from "@/lib/filter-columns"; +import { asc, desc, ilike, inArray, and, gte, lte, not, or, eq, count} from "drizzle-orm"; +import { countPos, selectPos } from "./repository"; + +import { contractEnvelopes, contractsDetailView, contractSigners,contracts } from "@/db/schema/contract"; +import { revalidatePath } from "next/cache"; +import * as z from "zod" +import { vendorContacts } from "@/db/schema/vendors"; + +/** + * PQ 목록 조회 + */ +export async function getPOs(input: GetPOSchema) { + return unstable_cache( + async () => { + try { + const offset = (input.page - 1) * input.perPage; + + // 1. Try a simple query first to make sure the view works at all + try { + const testQuery = await db.select({ count: count() }) + .from(contractsDetailView); + console.log("Test query result:", testQuery); + } catch (testErr) { + console.error("Test query failed:", testErr); + } + + // 2. Build where clause with more careful handling + let advancedWhere; + try { + advancedWhere = filterColumns({ + table: contractsDetailView, + filters: input.filters, + joinOperator: input.joinOperator, + }); + console.log("Advanced where clause built successfully"); + } catch (whereErr) { + console.error("Error building advanced where:", whereErr); + advancedWhere = undefined; + } + + let globalWhere; + if (input.search) { + try { + const s = `%${input.search}%`; + globalWhere = or( + ilike(contractsDetailView.contractNo, s), + ilike(contractsDetailView.contractName, s), + ); + console.log("Global where clause built successfully"); + } catch (searchErr) { + console.error("Error building search where:", searchErr); + globalWhere = undefined; + } + } + + // 3. Combine where clauses safely + let finalWhere; + if (advancedWhere && globalWhere) { + finalWhere = and(advancedWhere, globalWhere); + } else { + finalWhere = advancedWhere || globalWhere; + } + + + // 4. Build order by + let orderBy; + try { + orderBy = input.sort.length > 0 + ? input.sort.map((item) => + item.desc ? desc(contractsDetailView[item.id]) : asc(contractsDetailView[item.id]) + ) + : [asc(contractsDetailView.createdAt)]; + } catch (orderErr) { + console.error("Error building order by:", orderErr); + orderBy = [asc(contractsDetailView.createdAt)]; + } + + // 5. Execute queries with proper error handling + let data = []; + let total = 0; + + try { + // Try without transaction first for better error visibility + const queryBuilder = db.select() + .from(contractsDetailView); + + // Add where clause if it exists + if (finalWhere) { + queryBuilder.where(finalWhere); + } + + // Add ordering + queryBuilder.orderBy(...orderBy); + + // Add pagination + queryBuilder.offset(offset).limit(input.perPage); + + // Execute query + data = await queryBuilder; + + // Get total count + const countBuilder = db.select({ count: count() }) + .from(contractsDetailView); + + if (finalWhere) { + countBuilder.where(finalWhere); + } + + const countResult = await countBuilder; + total = countResult[0]?.count || 0; + + } catch (queryErr) { + console.error("Query execution failed:", queryErr); + throw queryErr; // Rethrow to be caught by the outer try/catch + } + + const pageCount = Math.ceil(total / input.perPage); + + return { data, pageCount }; + } catch (err) { + // More detailed error logging + console.error("Error in getPOs:", err); + if (err instanceof Error) { + console.error("Error message:", err.message); + console.error("Error stack:", err.stack); + } + return { data: [], pageCount: 0 }; + } + }, + [JSON.stringify(input)], + { + revalidate: 3600, + tags: [`po`], + } + )(); +} + +// Schema for a single signer +const signerSchema = z.object({ + signerEmail: z.string().email(), + signerName: z.string().min(1), + signerPosition: z.string(), + signerType: z.enum(["REQUESTER", "VENDOR"]), + vendorContactId: z.number().optional(), + }); + + // Schema for the entire request + const signatureRequestSchema = z.object({ + contractId: z.number(), + signers: z.array(signerSchema).min(1, "At least one signer is required") + }); + + /** + * Server action to request electronic signatures for a contract from multiple parties + */ + export async function requestSignatures( + input: z.infer<typeof signatureRequestSchema> + ): Promise<{ success: boolean; message: string }> { + try { + // Validate the input + const validatedData = signatureRequestSchema.parse(input); + + // Use a transaction to ensure data consistency + return await db.transaction(async (tx) => { + // Get contract details using standard select + const [contract] = await tx + .select() + .from(contracts) + .where(eq(contracts.id, validatedData.contractId)) + .limit(1); + + if (!contract) { + throw new Error(`Contract with ID ${validatedData.contractId} not found`); + } + + // Generate unique envelope ID + const envelopeId = `env-${Date.now()}-${Math.floor(Math.random() * 1000)}`; + + // Get contract number or fallback + const contractNo = contract.contractNo || `contract-${validatedData.contractId}`; + + // Create a single envelope for all signers + const [newEnvelope] = await tx.insert(contractEnvelopes) + .values({ + contractId: validatedData.contractId, + envelopeId: envelopeId, + envelopeStatus: "sent", + fileName: `${contractNo}-signature.pdf`, // Required field + filePath: `/contracts/${validatedData.contractId}/signatures/${envelopeId}.pdf`, // Required field + // Add any other required fields based on your schema + }) + .returning(); + + // Check for duplicate emails + const signerEmails = new Set(); + for (const signer of validatedData.signers) { + if (signerEmails.has(signer.signerEmail)) { + throw new Error(`Duplicate signer email: ${signer.signerEmail}`); + } + signerEmails.add(signer.signerEmail); + } + + // Create signer records for each signer + for (const signer of validatedData.signers) { + await tx.insert(contractSigners) + .values({ + envelopeId: newEnvelope.id, + signerEmail: signer.signerEmail, + signerName: signer.signerName, + signerPosition: signer.signerPosition, + signerStatus: "sent", + signerType: signer.signerType, + // Only include vendorContactId if it's provided and the signer is a vendor + ...(signer.vendorContactId && signer.signerType === "VENDOR" + ? { vendorContactId: signer.vendorContactId } + : {}) + }); + } + + // Update contract status to indicate pending signatures + await tx.update(contracts) + .set({ status: "PENDING_SIGNATURE" }) + .where(eq(contracts.id, validatedData.contractId)); + + // In a real implementation, you would send the envelope to DocuSign or similar service + // For example: + // const docusignResult = await docusignClient.createEnvelope({ + // recipients: validatedData.signers.map(signer => ({ + // email: signer.signerEmail, + // name: signer.signerName, + // recipientType: signer.signerType === "REQUESTER" ? "signer" : "cc", + // routingOrder: signer.signerType === "REQUESTER" ? 1 : 2, + // })), + // documentId: `contract-${validatedData.contractId}`, + // // other DocuSign-specific parameters + // }); + + // Revalidate the path to refresh the data + revalidatePath("/po"); + + // Return success response + return { + success: true, + message: `Signature requests sent to ${validatedData.signers.length} recipient(s)` + }; + }); + } catch (error) { + console.error("Error requesting electronic signatures:", error); + return { + success: false, + message: error instanceof Error ? error.message : "Failed to send signature requests" + }; + } + } + + export async function getVendorContacts(vendorId: number) { + try { + const contacts = await db + .select({ + id: vendorContacts.id, + contactName: vendorContacts.contactName, + contactEmail: vendorContacts.contactEmail, + contactPosition: vendorContacts.contactPosition, + contactPhone: vendorContacts.contactPhone, + isPrimary: vendorContacts.isPrimary, + }) + .from(vendorContacts) + .where(eq(vendorContacts.vendorId, vendorId)) + .orderBy(vendorContacts.isPrimary, vendorContacts.contactName); + + return contacts; + } catch (error) { + console.error("Error fetching vendor contacts:", error); + throw new Error("Failed to fetch vendor contacts"); + } + }
\ No newline at end of file diff --git a/lib/po/table/feature-flags-provider.tsx b/lib/po/table/feature-flags-provider.tsx new file mode 100644 index 00000000..81131894 --- /dev/null +++ b/lib/po/table/feature-flags-provider.tsx @@ -0,0 +1,108 @@ +"use client" + +import * as React from "react" +import { useQueryState } from "nuqs" + +import { dataTableConfig, type DataTableConfig } from "@/config/data-table" +import { cn } from "@/lib/utils" +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" + +type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"] + +interface FeatureFlagsContextProps { + featureFlags: FeatureFlagValue[] + setFeatureFlags: (value: FeatureFlagValue[]) => void +} + +const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({ + featureFlags: [], + setFeatureFlags: () => {}, +}) + +export function useFeatureFlags() { + const context = React.useContext(FeatureFlagsContext) + if (!context) { + throw new Error( + "useFeatureFlags must be used within a FeatureFlagsProvider" + ) + } + return context +} + +interface FeatureFlagsProviderProps { + children: React.ReactNode +} + +export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) { + const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>( + "flags", + { + defaultValue: [], + parse: (value) => value.split(",") as FeatureFlagValue[], + serialize: (value) => value.join(","), + eq: (a, b) => + a.length === b.length && a.every((value, index) => value === b[index]), + clearOnDefault: true, + shallow: false, + } + ) + + return ( + <FeatureFlagsContext.Provider + value={{ + featureFlags, + setFeatureFlags: (value) => void setFeatureFlags(value), + }} + > + <div className="w-full overflow-x-auto"> + <ToggleGroup + type="multiple" + variant="outline" + size="sm" + value={featureFlags} + onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)} + className="w-fit gap-0" + > + {dataTableConfig.featureFlags.map((flag, index) => ( + <Tooltip key={flag.value}> + <ToggleGroupItem + value={flag.value} + className={cn( + "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90", + { + "rounded-l-sm border-r-0": index === 0, + "rounded-r-sm": + index === dataTableConfig.featureFlags.length - 1, + } + )} + asChild + > + <TooltipTrigger> + <flag.icon className="size-3.5 shrink-0" aria-hidden="true" /> + {flag.label} + </TooltipTrigger> + </ToggleGroupItem> + <TooltipContent + align="start" + side="bottom" + sideOffset={6} + className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground" + > + <div>{flag.tooltipTitle}</div> + <div className="text-xs text-muted-foreground"> + {flag.tooltipDescription} + </div> + </TooltipContent> + </Tooltip> + ))} + </ToggleGroup> + </div> + {children} + </FeatureFlagsContext.Provider> + ) +} diff --git a/lib/po/table/po-table-columns.tsx b/lib/po/table/po-table-columns.tsx new file mode 100644 index 00000000..c2c01136 --- /dev/null +++ b/lib/po/table/po-table-columns.tsx @@ -0,0 +1,155 @@ +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { InfoIcon, PenIcon } from "lucide-react" + +import { formatDate } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" + +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { poColumnsConfig } from "@/config/poColumnsConfig" +import { ContractDetail } from "@/db/schema/contract" + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<ContractDetail> | null>> +} + +/** + * tanstack table column definitions with nested headers + */ +export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<ContractDetail>[] { + // ---------------------------------------------------------------- + // 1) select column (checkbox) - if needed + // ---------------------------------------------------------------- + + // ---------------------------------------------------------------- + // 2) actions column (buttons for item info and signature request) + // ---------------------------------------------------------------- + const actionsColumn: ColumnDef<ContractDetail> = { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + // Check if this contract already has a signature envelope + const hasSignature = row.original.hasSignature; + + return ( + <div className="flex items-center space-x-1"> + {/* Item Info Button */} + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="icon" + onClick={() => setRowAction({ row, type: "items" })} + > + <InfoIcon className="h-4 w-4" aria-hidden="true" /> + </Button> + </TooltipTrigger> + <TooltipContent> + View Item Info + </TooltipContent> + </Tooltip> + </TooltipProvider> + + {/* Signature Request Button - only show if no signature exists */} + {!hasSignature && ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="icon" + onClick={() => setRowAction({ row, type: "signature" })} + > + <PenIcon className="h-4 w-4" aria-hidden="true" /> + </Button> + </TooltipTrigger> + <TooltipContent> + Request Electronic Signature + </TooltipContent> + </Tooltip> + </TooltipProvider> + )} + </div> + ); + }, + size: 80, // Increased width to accommodate both buttons + }; + + // ---------------------------------------------------------------- + // 3) Regular columns grouped by group name + // ---------------------------------------------------------------- + // 3-1) groupMap: { [groupName]: ColumnDef<ContractDetail>[] } + const groupMap: Record<string, ColumnDef<ContractDetail>[]> = {}; + + poColumnsConfig.forEach((cfg) => { + // Use "_noGroup" if no group is specified + const groupName = cfg.group || "_noGroup"; + + if (!groupMap[groupName]) { + groupMap[groupName] = []; + } + + // Child column definition + const childCol: ColumnDef<ContractDetail> = { + accessorKey: cfg.id, + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title={cfg.label} /> + ), + meta: { + excelHeader: cfg.excelHeader, + group: cfg.group, + type: cfg.type, + }, + cell: ({ row, cell }) => { + if (cfg.id === "createdAt" || cfg.id === "updatedAt") { + const dateVal = cell.getValue() as Date; + return formatDate(dateVal); + } + + return row.getValue(cfg.id) ?? ""; + }, + }; + + groupMap[groupName].push(childCol); + }); + + // ---------------------------------------------------------------- + // 3-2) Create actual parent columns (groups) from the groupMap + // ---------------------------------------------------------------- + const nestedColumns: ColumnDef<ContractDetail>[] = []; + + // Order can be fixed by pre-defining group order or sorting + // Here we just use Object.entries order + Object.entries(groupMap).forEach(([groupName, colDefs]) => { + if (groupName === "_noGroup") { + // No group → Add as top-level columns + nestedColumns.push(...colDefs); + } else { + // Parent column + nestedColumns.push({ + id: groupName, + header: groupName, // "Basic Info", "Metadata", etc. + columns: colDefs, + }); + } + }); + + // ---------------------------------------------------------------- + // 4) Final column array: nestedColumns + actionsColumn + // ---------------------------------------------------------------- + return [ + ...nestedColumns, + actionsColumn, + ]; +}
\ No newline at end of file diff --git a/lib/po/table/po-table-toolbar-actions.tsx b/lib/po/table/po-table-toolbar-actions.tsx new file mode 100644 index 00000000..e6c8e79a --- /dev/null +++ b/lib/po/table/po-table-toolbar-actions.tsx @@ -0,0 +1,53 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Download, RefreshCcw, Upload } from "lucide-react" +import { toast } from "sonner" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" +import { ContractDetail } from "@/db/schema/contract" + + + +interface ItemsTableToolbarActionsProps { + table: Table<ContractDetail> +} + +export function PoTableToolbarActions({ table }: ItemsTableToolbarActionsProps) { + // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식 + const fileInputRef = React.useRef<HTMLInputElement>(null) + + + + return ( + <div className="flex items-center gap-2"> + {/** 4) Export 버튼 */} + <Button + variant="samsung" + size="sm" + className="gap-2" + > + <RefreshCcw className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Get POs</span> + </Button> + + {/** 4) Export 버튼 */} + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(table, { + filename: "tasks", + excludeColumns: ["select", "actions"], + }) + } + className="gap-2" + > + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Export</span> + </Button> + </div> + ) +}
\ No newline at end of file diff --git a/lib/po/table/po-table.tsx b/lib/po/table/po-table.tsx new file mode 100644 index 00000000..49fbdda4 --- /dev/null +++ b/lib/po/table/po-table.tsx @@ -0,0 +1,164 @@ +"use client" + +import * as React from "react" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" + +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import { useFeatureFlags } from "./feature-flags-provider" +import { toast } from "sonner" + +import { getPOs, requestSignatures } from "../service" +import { getColumns } from "./po-table-columns" +import { ContractDetail } from "@/db/schema/contract" +import { PoTableToolbarActions } from "./po-table-toolbar-actions" +import { SignatureRequestModal } from "./sign-request-dialog" + +interface ItemsTableProps { + promises: Promise< + [ + Awaited<ReturnType<typeof getPOs>>, + ] + > +} + +// Interface for signing party +interface SigningParty { + signerEmail: string; + signerName: string; + signerPosition: string; + signerType: "REQUESTER" | "VENDOR"; + vendorContactId?: number; +} + +export function PoListsTable({ promises }: ItemsTableProps) { + const { featureFlags } = useFeatureFlags() + + const [{ data, pageCount }] = + React.use(promises) + + const [rowAction, setRowAction] = + React.useState<DataTableRowAction<ContractDetail> | null>(null) + + // State for signature request modal + const [signatureModalOpen, setSignatureModalOpen] = React.useState(false) + const [selectedContract, setSelectedContract] = React.useState<ContractDetail | null>(null) + + // Handle row actions + React.useEffect(() => { + if (!rowAction) return + + if (rowAction.type === "signature") { + // Open signature request modal with the selected contract + setSelectedContract(rowAction.row.original) + setSignatureModalOpen(true) + setRowAction(null) + } else if (rowAction.type === "items") { + // Existing handler for "items" action type + // Your existing code here + setRowAction(null) + } + }, [rowAction]) + + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + // Updated handler to work with multiple signers + const handleSignatureRequest = async ( + values: { signers: SigningParty[] }, + contractId: number + ): Promise<void> => { + try { + const result = await requestSignatures({ + contractId, + signers: values.signers + }); + + // Handle the result + if (result.success) { + toast.success(result.message || "Signature requests sent successfully"); + } else { + toast.error(result.message || "Failed to send signature requests"); + } + } catch (error) { + console.error("Error sending signature requests:", error); + toast.error("An error occurred while sending the signature requests"); + } + } + + const filterFields: DataTableFilterField<ContractDetail>[] = [ + // Your existing filter fields + ] + + const advancedFilterFields: DataTableAdvancedFilterField<ContractDetail>[] = [ + { + id: "contractNo", + label: "Contract No", + type: "text", + }, + { + id: "contractName", + label: "Contract Name", + type: "text", + }, + { + id: "createdAt", + label: "Created At", + type: "date", + }, + { + id: "updatedAt", + label: "Updated At", + type: "date", + }, + ] + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + <DataTable + table={table} + > + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <PoTableToolbarActions table={table} /> + </DataTableAdvancedToolbar> + </DataTable> + + {/* Enhanced Dual Signature Request Modal */} + {selectedContract && ( + <SignatureRequestModal + contract={selectedContract} + open={signatureModalOpen} + onOpenChange={setSignatureModalOpen} + onSubmit={handleSignatureRequest} + /> + )} + </> + ) +}
\ No newline at end of file diff --git a/lib/po/table/sign-request-dialog.tsx b/lib/po/table/sign-request-dialog.tsx new file mode 100644 index 00000000..f70e5e33 --- /dev/null +++ b/lib/po/table/sign-request-dialog.tsx @@ -0,0 +1,410 @@ +"use client" + +import { useState, useEffect } from "react" +import { z } from "zod" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { toast } from "sonner" + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Input } from "@/components/ui/input" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { ContractDetail } from "@/db/schema/contract" +import { getVendorContacts } from "../service" + +// Type for vendor contact +interface VendorContact { + id: number + contactName: string + contactEmail: string + contactPosition: string | null + isPrimary: boolean +} + +// Form schema for signature request +const signatureRequestSchema = z.object({ + // Requester signer information + includeRequesterSigner: z.boolean().default(true), + requesterEmail: z.string().email("Please enter a valid email address").optional(), + requesterName: z.string().min(1, "Please enter the signer's name").optional(), + requesterPosition: z.string().optional(), + + // Vendor signer information + includeVendorSigner: z.boolean().default(true), + vendorContactId: z.number().optional(), +}).refine(data => data.includeRequesterSigner || data.includeVendorSigner, { + message: "At least one signer must be included", + path: ["includeRequesterSigner"] +}).refine(data => !data.includeRequesterSigner || (data.requesterEmail && data.requesterName), { + message: "Requester email and name are required", + path: ["requesterEmail"] +}).refine(data => !data.includeVendorSigner || data.vendorContactId, { + message: "Please select a vendor contact", + path: ["vendorContactId"] +}); + +type SignatureRequestFormValues = z.infer<typeof signatureRequestSchema> + +// Interface for signing parties +interface SigningParty { + signerEmail: string; + signerName: string; + signerPosition: string; + signerType: "REQUESTER" | "VENDOR"; + vendorContactId?: number; +} + +// Updated interface to accept multiple signers +interface SignatureRequestModalProps { + contract: ContractDetail + open: boolean + onOpenChange: (open: boolean) => void + onSubmit: ( + values: { + signers: SigningParty[] + }, + contractId: number + ) => Promise<{ success: boolean; message: string } | void> +} + +export function SignatureRequestModal({ + contract, + open, + onOpenChange, + onSubmit, +}: SignatureRequestModalProps) { + const [isSubmitting, setIsSubmitting] = useState(false) + const [vendorContacts, setVendorContacts] = useState<VendorContact[]>([]) + const [selectedVendorContact, setSelectedVendorContact] = useState<VendorContact | null>(null) + + const form = useForm<SignatureRequestFormValues>({ + resolver: zodResolver(signatureRequestSchema), + defaultValues: { + includeRequesterSigner: true, + requesterEmail: "", + requesterName: "", + requesterPosition: "", + includeVendorSigner: true, + vendorContactId: undefined, + }, + }) + + // Load vendor contacts when the modal opens + useEffect(() => { + if (open && contract?.vendorId) { + const loadVendorContacts = async () => { + try { + const contacts = await getVendorContacts(contract.vendorId); + setVendorContacts(contacts); + + // Auto-select primary contact if available + const primaryContact = contacts.find(c => c.isPrimary); + if (primaryContact) { + handleVendorContactSelect(primaryContact.id.toString()); + } + } catch (error) { + console.error("Error loading vendor contacts:", error); + toast.error("Failed to load vendor contacts"); + } + }; + + loadVendorContacts(); + } + }, [open, contract]); + + // Handle selection of a vendor contact + const handleVendorContactSelect = (contactId: string) => { + const id = Number(contactId); + form.setValue("vendorContactId", id); + + // Find the selected contact to show details + const contact = vendorContacts.find(c => c.id === id); + if (contact) { + setSelectedVendorContact(contact); + } + }; + + async function handleSubmit(values: SignatureRequestFormValues) { + setIsSubmitting(true); + + try { + const signers: SigningParty[] = []; + + // Add requester signer if included + if (values.includeRequesterSigner && values.requesterEmail && values.requesterName) { + signers.push({ + signerEmail: values.requesterEmail, + signerName: values.requesterName, + signerPosition: values.requesterPosition || "", + signerType: "REQUESTER" + }); + } + + // Add vendor signer if included + if (values.includeVendorSigner && values.vendorContactId && selectedVendorContact) { + signers.push({ + signerEmail: selectedVendorContact.contactEmail, + signerName: selectedVendorContact.contactName, + signerPosition: selectedVendorContact.contactPosition || "", + vendorContactId: values.vendorContactId, + signerType: "VENDOR" + }); + } + + if (signers.length === 0) { + throw new Error("At least one signer must be included"); + } + + const result = await onSubmit({ signers }, contract.id); + + // Handle the result if it exists + if (result && typeof result === 'object') { + if (result.success) { + toast.success(result.message || "Signature requests sent successfully"); + } else { + toast.error(result.message || "Failed to send signature requests"); + } + } else { + // If no result is returned, assume success + toast.success("Electronic signature requests sent successfully"); + } + + form.reset(); + onOpenChange(false); + } catch (error) { + console.error("Error sending signature requests:", error); + toast.error(error instanceof Error ? error.message : "Failed to send signature requests. Please try again."); + } finally { + setIsSubmitting(false); + } + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[600px]"> + <DialogHeader> + <DialogTitle>Request Electronic Signatures</DialogTitle> + <DialogDescription> + Send signature requests for contract: {contract?.contractName || ""} + </DialogDescription> + </DialogHeader> + + <Form {...form}> + <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6"> + <Accordion type="multiple" defaultValue={["requester", "vendor"]} className="w-full"> + {/* Requester Signature Section */} + <AccordionItem value="requester"> + <div className="flex items-center space-x-2"> + <FormField + control={form.control} + name="includeRequesterSigner" + render={({ field }) => ( + <FormItem className="flex flex-row items-center space-x-3 space-y-0 my-2"> + <FormControl> + <Checkbox + checked={field.value} + onCheckedChange={field.onChange} + /> + </FormControl> + <AccordionTrigger className="hover:no-underline ml-2"> + <div className="text-sm font-medium">Requester Signature</div> + </AccordionTrigger> + </FormItem> + )} + /> + </div> + <AccordionContent> + {form.watch("includeRequesterSigner") && ( + <Card className="border-none shadow-none"> + <CardContent className="p-0 space-y-4"> + <FormField + control={form.control} + name="requesterEmail" + render={({ field }) => ( + <FormItem> + <FormLabel>Signer Email</FormLabel> + <FormControl> + <Input placeholder="email@example.com" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="requesterName" + render={({ field }) => ( + <FormItem> + <FormLabel>Signer Name</FormLabel> + <FormControl> + <Input placeholder="Full Name" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="requesterPosition" + render={({ field }) => ( + <FormItem> + <FormLabel>Signer Position</FormLabel> + <FormControl> + <Input placeholder="e.g. CEO, Manager" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </CardContent> + </Card> + )} + </AccordionContent> + </AccordionItem> + + {/* Vendor Signature Section */} + <AccordionItem value="vendor"> + <div className="flex items-center space-x-2"> + <FormField + control={form.control} + name="includeVendorSigner" + render={({ field }) => ( + <FormItem className="flex flex-row items-center space-x-3 space-y-0 my-2"> + <FormControl> + <Checkbox + checked={field.value} + onCheckedChange={field.onChange} + /> + </FormControl> + <AccordionTrigger className="hover:no-underline ml-2"> + <div className="text-sm font-medium">Vendor Signature</div> + </AccordionTrigger> + </FormItem> + )} + /> + </div> + <AccordionContent> + {form.watch("includeVendorSigner") && ( + <Card className="border-none shadow-none"> + <CardContent className="p-0 space-y-4"> + <FormField + control={form.control} + name="vendorContactId" + render={({ field }) => ( + <FormItem> + <FormLabel>Select Vendor Contact</FormLabel> + <Select + onValueChange={handleVendorContactSelect} + defaultValue={field.value?.toString()} + > + <FormControl> + <SelectTrigger> + <SelectValue placeholder="Select a contact" /> + </SelectTrigger> + </FormControl> + <SelectContent> + {vendorContacts.length > 0 ? ( + vendorContacts.map((contact) => ( + <SelectItem + key={contact.id} + value={contact.id.toString()} + > + {contact.contactName} {contact.isPrimary ? "(Primary)" : ""} + </SelectItem> + )) + ) : ( + <SelectItem value="none" disabled> + No contacts available + </SelectItem> + )} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + {/* Display selected contact info (read-only) */} + {selectedVendorContact && ( + <> + <FormItem className="pb-2"> + <FormLabel>Contact Email</FormLabel> + <div className="p-2 border rounded-md bg-muted"> + {selectedVendorContact.contactEmail} + </div> + </FormItem> + + <FormItem className="pb-2"> + <FormLabel>Contact Name</FormLabel> + <div className="p-2 border rounded-md bg-muted"> + {selectedVendorContact.contactName} + </div> + </FormItem> + + <FormItem className="pb-2"> + <FormLabel>Contact Position</FormLabel> + <div className="p-2 border rounded-md bg-muted"> + {selectedVendorContact.contactPosition || "N/A"} + </div> + </FormItem> + </> + )} + </CardContent> + </Card> + )} + </AccordionContent> + </AccordionItem> + </Accordion> + + <DialogFooter> + <Button type="button" variant="outline" onClick={() => onOpenChange(false)}> + Cancel + </Button> + <Button type="submit" disabled={isSubmitting}> + {isSubmitting ? "Sending..." : "Send Requests"} + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/po/validations.ts b/lib/po/validations.ts new file mode 100644 index 00000000..c96d7277 --- /dev/null +++ b/lib/po/validations.ts @@ -0,0 +1,67 @@ +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, +} from "nuqs/server" +import * as z from "zod" + +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" +import { ContractDetail } from "@/db/schema/contract" + +// nuqs/server 에 parseAsBoolean, parseAsNumber 등이 없다면 +// 숫자/불리언으로 처리해야 할 필드도 우선 parseAsString / parseAsStringEnum 으로 받습니다. +// 실제 사용 시에는 후속 로직에서 변환(예: parseFloat 등)하세요. + +export const searchParamsCache = createSearchParamsCache({ + // UI 모드나 플래그 관련 + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + + // 페이징 + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + + // 정렬 (createdAt 기준 내림차순) + sort: getSortingStateParser<ContractDetail>().withDefault([ + { id: "createdAt", desc: true }, + ]), + projectCode: parseAsString.withDefault(""), + projectName: parseAsString.withDefault(""), + + // 기존 필드 + contractNo: parseAsString.withDefault(""), + contractName: parseAsString.withDefault(""), + status: parseAsString.withDefault(""), + startDate: parseAsString.withDefault(""), // 문자열 "YYYY-MM-DD" 형태 + endDate: parseAsString.withDefault(""), // 마찬가지 + + // 추가된 PO 관련 필드 + paymentTerms: parseAsString.withDefault(""), + deliveryTerms: parseAsString.withDefault(""), + deliveryDate: parseAsString.withDefault(""), // "YYYY-MM-DD" + deliveryLocation: parseAsString.withDefault(""), + + // 금액 관련 (문자열로 받고 후처리에서 parseFloat 권장) + currency: parseAsString.withDefault("KRW"), + totalAmount: parseAsString.withDefault(""), + discount: parseAsString.withDefault(""), + tax: parseAsString.withDefault(""), + shippingFee: parseAsString.withDefault(""), + netTotal: parseAsString.withDefault(""), + + // 부분 납품/결제 허용 여부 (문자열 "true"/"false") + partialShippingAllowed: parseAsStringEnum(["true", "false"]).withDefault("false"), + partialPaymentAllowed: parseAsStringEnum(["true", "false"]).withDefault("false"), + + remarks: parseAsString.withDefault(""), + version: parseAsString.withDefault(""), + + // 고급 필터(Advanced) & 검색 + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), +}) + +// 최종 타입 +export type GetPOSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>
\ No newline at end of file diff --git a/lib/pq/pq-review-table/feature-flags-provider.tsx b/lib/pq/pq-review-table/feature-flags-provider.tsx new file mode 100644 index 00000000..81131894 --- /dev/null +++ b/lib/pq/pq-review-table/feature-flags-provider.tsx @@ -0,0 +1,108 @@ +"use client" + +import * as React from "react" +import { useQueryState } from "nuqs" + +import { dataTableConfig, type DataTableConfig } from "@/config/data-table" +import { cn } from "@/lib/utils" +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" + +type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"] + +interface FeatureFlagsContextProps { + featureFlags: FeatureFlagValue[] + setFeatureFlags: (value: FeatureFlagValue[]) => void +} + +const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({ + featureFlags: [], + setFeatureFlags: () => {}, +}) + +export function useFeatureFlags() { + const context = React.useContext(FeatureFlagsContext) + if (!context) { + throw new Error( + "useFeatureFlags must be used within a FeatureFlagsProvider" + ) + } + return context +} + +interface FeatureFlagsProviderProps { + children: React.ReactNode +} + +export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) { + const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>( + "flags", + { + defaultValue: [], + parse: (value) => value.split(",") as FeatureFlagValue[], + serialize: (value) => value.join(","), + eq: (a, b) => + a.length === b.length && a.every((value, index) => value === b[index]), + clearOnDefault: true, + shallow: false, + } + ) + + return ( + <FeatureFlagsContext.Provider + value={{ + featureFlags, + setFeatureFlags: (value) => void setFeatureFlags(value), + }} + > + <div className="w-full overflow-x-auto"> + <ToggleGroup + type="multiple" + variant="outline" + size="sm" + value={featureFlags} + onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)} + className="w-fit gap-0" + > + {dataTableConfig.featureFlags.map((flag, index) => ( + <Tooltip key={flag.value}> + <ToggleGroupItem + value={flag.value} + className={cn( + "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90", + { + "rounded-l-sm border-r-0": index === 0, + "rounded-r-sm": + index === dataTableConfig.featureFlags.length - 1, + } + )} + asChild + > + <TooltipTrigger> + <flag.icon className="size-3.5 shrink-0" aria-hidden="true" /> + {flag.label} + </TooltipTrigger> + </ToggleGroupItem> + <TooltipContent + align="start" + side="bottom" + sideOffset={6} + className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground" + > + <div>{flag.tooltipTitle}</div> + <div className="text-xs text-muted-foreground"> + {flag.tooltipDescription} + </div> + </TooltipContent> + </Tooltip> + ))} + </ToggleGroup> + </div> + {children} + </FeatureFlagsContext.Provider> + ) +} diff --git a/lib/pq/pq-review-table/vendors-table-columns.tsx b/lib/pq/pq-review-table/vendors-table-columns.tsx new file mode 100644 index 00000000..8673443f --- /dev/null +++ b/lib/pq/pq-review-table/vendors-table-columns.tsx @@ -0,0 +1,212 @@ +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { Ellipsis, PaperclipIcon } from "lucide-react" +import { toast } from "sonner" + +import { getErrorMessage } from "@/lib/handle-error" +import { formatDate } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header" +import { useRouter } from "next/navigation" + +import { Vendor, vendors, VendorWithAttachments } from "@/db/schema/vendors" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { vendorColumnsConfig } from "@/config/vendorColumnsConfig" +import { Separator } from "@/components/ui/separator" + + +type NextRouter = ReturnType<typeof useRouter>; + + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<Vendor> | null>>; + router: NextRouter; +} + +/** + * tanstack table 컬럼 정의 (중첩 헤더 버전) + */ +export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef<Vendor>[] { + // ---------------------------------------------------------------- + // 1) select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef<Vendor> = { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + } + + // ---------------------------------------------------------------- + // 2) actions 컬럼 (Dropdown 메뉴) + // ---------------------------------------------------------------- + const actionsColumn: ColumnDef<Vendor> = { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-8 p-0 data-[state=open]:bg-muted" + > + <Ellipsis className="size-4" aria-hidden="true" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-40"> + + <DropdownMenuItem + onSelect={() => { + // 1) 만약 rowAction을 열고 싶다면 + // setRowAction({ row, type: "update" }) + + // 2) 자세히 보기 페이지로 클라이언트 라우팅 + router.push(`/evcp/pq/${row.original.id}`); + }} + > + Details + </DropdownMenuItem> + + + </DropdownMenuContent> + </DropdownMenu> + ) + }, + size: 40, + } + + // ---------------------------------------------------------------- + // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 + // ---------------------------------------------------------------- + // 3-1) groupMap: { [groupName]: ColumnDef<Vendor>[] } + const groupMap: Record<string, ColumnDef<Vendor>[]> = {} + + vendorColumnsConfig.forEach((cfg) => { + // 만약 group가 없으면 "_noGroup" 처리 + const groupName = cfg.group || "_noGroup" + + if (!groupMap[groupName]) { + groupMap[groupName] = [] + } + + // child column 정의 + const childCol: ColumnDef<Vendor> = { + accessorKey: cfg.id, + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title={cfg.label} /> + ), + meta: { + excelHeader: cfg.excelHeader, + group: cfg.group, + type: cfg.type, + }, + cell: ({ row, cell }) => { + + + if (cfg.id === "status") { + const statusVal = row.original.status + if (!statusVal) return null + // const Icon = getStatusIcon(statusVal) + return ( + <div className="flex w-[6.25rem] items-center"> + {/* <Icon className="mr-2 size-4 text-muted-foreground" aria-hidden="true" /> */} + <span className="capitalize">{statusVal}</span> + </div> + ) + } + + + if (cfg.id === "createdAt") { + const dateVal = cell.getValue() as Date + return formatDate(dateVal) + } + + if (cfg.id === "updatedAt") { + const dateVal = cell.getValue() as Date + return formatDate(dateVal) + } + + + // code etc... + return row.getValue(cfg.id) ?? "" + }, + } + + groupMap[groupName].push(childCol) + }) + + // ---------------------------------------------------------------- + // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기 + // ---------------------------------------------------------------- + const nestedColumns: ColumnDef<Vendor>[] = [] + + // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함 + // 여기서는 그냥 Object.entries 순서 + Object.entries(groupMap).forEach(([groupName, colDefs]) => { + if (groupName === "_noGroup") { + // 그룹 없음 → 그냥 최상위 레벨 컬럼 + nestedColumns.push(...colDefs) + } else { + // 상위 컬럼 + nestedColumns.push({ + id: groupName, + header: groupName, // "Basic Info", "Metadata" 등 + columns: colDefs, + }) + } + }) + + + + + // ---------------------------------------------------------------- + // 4) 최종 컬럼 배열: select, nestedColumns, actions + // ---------------------------------------------------------------- + return [ + selectColumn, + ...nestedColumns, + actionsColumn, + ] +}
\ No newline at end of file diff --git a/lib/pq/pq-review-table/vendors-table-toolbar-actions.tsx b/lib/pq/pq-review-table/vendors-table-toolbar-actions.tsx new file mode 100644 index 00000000..98fef170 --- /dev/null +++ b/lib/pq/pq-review-table/vendors-table-toolbar-actions.tsx @@ -0,0 +1,41 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Download, Upload, Check } from "lucide-react" +import { toast } from "sonner" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" +import { Vendor } from "@/db/schema/vendors" + +interface VendorsTableToolbarActionsProps { + table: Table<Vendor> +} + +export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActionsProps) { + // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식 + + + return ( + <div className="flex items-center gap-2"> + + + {/** 4) Export 버튼 */} + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(table, { + filename: "vendors", + excludeColumns: ["select", "actions"], + }) + } + className="gap-2" + > + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Export</span> + </Button> + </div> + ) +}
\ No newline at end of file diff --git a/lib/pq/pq-review-table/vendors-table.tsx b/lib/pq/pq-review-table/vendors-table.tsx new file mode 100644 index 00000000..7eb8f7de --- /dev/null +++ b/lib/pq/pq-review-table/vendors-table.tsx @@ -0,0 +1,97 @@ +"use client" + +import * as React from "react" +import { useRouter } from "next/navigation" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" + +import { toSentenceCase } from "@/lib/utils" +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import { useFeatureFlags } from "./feature-flags-provider" +import { getColumns } from "./vendors-table-columns" +import { Vendor, vendors } from "@/db/schema/vendors" +import { VendorsTableToolbarActions } from "./vendors-table-toolbar-actions" +import { getVendorsInPQ } from "../service" + + +interface VendorsTableProps { + promises: Promise< + [ + Awaited<ReturnType<typeof getVendorsInPQ>>, + ] + > +} + +export function VendorsPQReviewTable({ promises }: VendorsTableProps) { + const { featureFlags } = useFeatureFlags() + + // Suspense로 받아온 데이터 + const [{ data, pageCount }] = React.use(promises) + + const [rowAction, setRowAction] = React.useState<DataTableRowAction<Vendor> | null>(null) + + // **router** 획득 + const router = useRouter() + + // getColumns() 호출 시, router를 주입 + const columns = React.useMemo( + () => getColumns({ setRowAction, router }), + [setRowAction, router] + ) + + const filterFields: DataTableFilterField<Vendor>[] = [ + + + { id: "vendorCode", label: "Vendor Code" }, + + ] + + const advancedFilterFields: DataTableAdvancedFilterField<Vendor>[] = [ + { id: "vendorName", label: "Vendor Name", type: "text" }, + { id: "vendorCode", label: "Vendor Code", type: "text" }, + { id: "email", label: "Email", type: "text" }, + { id: "country", label: "Country", type: "text" }, + + { id: "createdAt", label: "Created at", type: "date" }, + { id: "updatedAt", label: "Updated at", type: "date" }, + ] + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + <DataTable + table={table} + // floatingBar={<VendorsTableFloatingBar table={table} />} + > + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <VendorsTableToolbarActions table={table} /> + </DataTableAdvancedToolbar> + </DataTable> + + </> + ) +}
\ No newline at end of file diff --git a/lib/pq/repository.ts b/lib/pq/repository.ts new file mode 100644 index 00000000..95daf9a3 --- /dev/null +++ b/lib/pq/repository.ts @@ -0,0 +1,44 @@ +import db from "@/db/db"; +import { pqCriterias } from "@/db/schema/pq"; +import { + eq, + inArray, + not, + asc, + desc, + and, + ilike, + gte, + lte, + count, + gt, +} from "drizzle-orm"; +import { PgTransaction } from "drizzle-orm/pg-core"; + +export async function selectPqs( + tx: PgTransaction<any, any, any>, + params: { + where?: any; // drizzle-orm의 조건식 (and, eq...) 등 + orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[]; + offset?: number; + limit?: number; + } +) { + const { where, orderBy, offset = 0, limit = 10 } = params; + + return tx + .select() + .from(pqCriterias) + .where(where) + .orderBy(...(orderBy ?? [])) + .offset(offset) + .limit(limit); +} +/** 총 개수 count */ +export async function countPqs( + tx: PgTransaction<any, any, any>, + where?: any +) { + const res = await tx.select({ count: count() }).from(pqCriterias).where(where); + return res[0]?.count ?? 0; +} diff --git a/lib/pq/service.ts b/lib/pq/service.ts new file mode 100644 index 00000000..a1373dae --- /dev/null +++ b/lib/pq/service.ts @@ -0,0 +1,987 @@ +"use server" + +import db from "@/db/db" +import { GetPQSchema } from "./validations" +import { unstable_cache } from "@/lib/unstable-cache"; +import { filterColumns } from "@/lib/filter-columns"; +import { getErrorMessage } from "@/lib/handle-error"; +import { asc, desc, ilike, inArray, and, gte, lte, not, or, eq, count} from "drizzle-orm"; +import { z } from "zod" +import { revalidateTag, unstable_noStore, revalidatePath} from "next/cache"; +import { pqCriterias, vendorCriteriaAttachments, vendorPqCriteriaAnswers, vendorPqReviewLogs } from "@/db/schema/pq" +import { countPqs, selectPqs } from "./repository"; +import { sendEmail } from "../mail/sendEmail"; +import { vendorAttachments, vendors } from "@/db/schema/vendors"; +import path from 'path'; +import fs from 'fs/promises'; +import { randomUUID } from 'crypto'; +import { writeFile, mkdir } from 'fs/promises'; +import { GetVendorsSchema } from "../vendors/validations"; +import { countVendors, selectVendors } from "../vendors/repository"; + +/** + * PQ 목록 조회 + */ +export async function getPQs(input: GetPQSchema) { + return unstable_cache( + async () => { + try { + const offset = (input.page - 1) * input.perPage; + + // advancedTable 모드면 filterColumns()로 where 절 구성 + const advancedWhere = filterColumns({ + table: pqCriterias, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + let globalWhere + if (input.search) { + const s = `%${input.search}%` + globalWhere = or(ilike(pqCriterias.code, s), ilike(pqCriterias.groupName, s), ilike(pqCriterias.remarks, s), ilike(pqCriterias.checkPoint, s), ilike(pqCriterias.description, s) + ) + } + + const finalWhere = and(advancedWhere, globalWhere); + const orderBy = + input.sort.length > 0 + ? input.sort.map((item) => + item.desc ? desc(pqCriterias[item.id]) : asc(pqCriterias[item.id]) + ) + : [asc(pqCriterias.createdAt)]; + + const { data, total } = await db.transaction(async (tx) => { + const data = await selectPqs(tx, { + where: finalWhere, + orderBy, + offset, + limit: input.perPage, + }); + const total = await countPqs(tx, finalWhere); + return { data, total }; + }); + + const pageCount = Math.ceil(total / input.perPage); + + + return { data, pageCount }; + } catch (err) { + return { data: [], pageCount: 0 }; + } + }, + [JSON.stringify(input)], + { + revalidate: 3600, + tags: [`pq`], + } + )(); +} + +// PQ 생성을 위한 입력 스키마 정의 +const createPqSchema = z.object({ + code: z.string().min(1, "Code is required"), + checkPoint: z.string().min(1, "Check point is required"), + description: z.string().optional(), + remarks: z.string().optional(), + groupName: z.string().optional() +}); + +export type CreatePqInputType = z.infer<typeof createPqSchema>; + +/** + * PQ 기준 생성 + */ +export async function createPq(input: CreatePqInputType) { + try { + // 입력 유효성 검증 + const validatedData = createPqSchema.parse(input); + + // 트랜잭션 사용하여 PQ 기준 생성 + return await db.transaction(async (tx) => { + // PQ 기준 생성 + const [newPqCriteria] = await tx + .insert(pqCriterias) + .values({ + code: validatedData.code, + checkPoint: validatedData.checkPoint, + description: validatedData.description || null, + remarks: validatedData.remarks || null, + groupName: validatedData.groupName || null, + }) + .returning({ id: pqCriterias.id }); + + // 성공 결과 반환 + return { + success: true, + pqId: newPqCriteria.id, + message: "PQ criteria created successfully" + }; + }); + } catch (error) { + console.error("Error creating PQ criteria:", error); + + // Zod 유효성 검사 에러 처리 + if (error instanceof z.ZodError) { + return { + success: false, + message: "Validation failed", + errors: error.errors + }; + } + + // 기타 에러 처리 + return { + success: false, + message: "Failed to create PQ criteria" + }; + } +} + +// PQ 캐시 무효화 함수 +export async function invalidatePqCache() { + revalidatePath(`/evcp/pq-criteria`); + revalidateTag(`pq`); +} + +// PQ 삭제를 위한 스키마 정의 +const removePqsSchema = z.object({ + ids: z.array(z.number()).min(1, "At least one PQ ID is required") +}); + +export type RemovePqsInputType = z.infer<typeof removePqsSchema>; + +/** + * PQ 기준 삭제 + */ +export async function removePqs(input: RemovePqsInputType) { + try { + // 입력 유효성 검증 + const validatedData = removePqsSchema.parse(input); + + // 트랜잭션 사용하여 PQ 기준 삭제 + await db.transaction(async (tx) => { + // PQ 기준 테이블에서 삭제 + await tx + .delete(pqCriterias) + .where(inArray(pqCriterias.id, validatedData.ids)); + }); + + // 캐시 무효화 + await invalidatePqCache(); + + return { success: true }; + } catch (error) { + console.error("Error removing PQ criteria:", error); + + // Zod 유효성 검사 에러 처리 + if (error instanceof z.ZodError) { + return { + success: false, + error: "Validation failed: " + error.errors.map(e => e.message).join(', ') + }; + } + + // 기타 에러 처리 + return { + success: false, + error: "Failed to remove PQ criteria" + }; + } +} + +// PQ 수정을 위한 스키마 정의 +const modifyPqSchema = z.object({ + id: z.number().positive("ID is required"), + code: z.string().min(1, "Code is required"), + checkPoint: z.string().min(1, "Check point is required"), + groupName: z.string().min(1, "Group is required"), + description: z.string().optional(), + remarks: z.string().optional() +}); + +export type ModifyPqInputType = z.infer<typeof modifyPqSchema>; + + +export async function modifyPq(input: ModifyPqInputType) { + try { + // 입력 유효성 검증 + const validatedData = modifyPqSchema.parse(input); + + // 트랜잭션 사용하여 PQ 기준 수정 + return await db.transaction(async (tx) => { + // PQ 기준 수정 + await tx + .update(pqCriterias) + .set({ + code: validatedData.code, + checkPoint: validatedData.checkPoint, + description: validatedData.description || null, + remarks: validatedData.remarks || null, + groupName: validatedData.groupName, + updatedAt: new Date(), + }) + .where(eq(pqCriterias.id, validatedData.id)); + + // 성공 결과 반환 + return { + success: true, + message: "PQ criteria updated successfully" + }; + }); + } catch (error) { + console.error("Error updating PQ criteria:", error); + + // Zod 유효성 검사 에러 처리 + if (error instanceof z.ZodError) { + return { + success: false, + error: "Validation failed: " + error.errors.map(e => e.message).join(', ') + }; + } + + // 기타 에러 처리 + return { + success: false, + error: "Failed to update PQ criteria" + }; + } finally { + // 캐시 무효화 + revalidatePath(`/partners/pq`); + revalidateTag(`pq`); + } +} + +export interface PQAttachment { + attachId: number + fileName: string + filePath: string + fileSize?: number +} + +export interface PQItem { + answerId: number | null; // null도 허용하도록 변경 + criteriaId: number + code: string + checkPoint: string + description: string | null + answer: string // or null + attachments: PQAttachment[] +} + +export interface PQGroupData { + groupName: string + items: PQItem[] +} + + +export async function getPQDataByVendorId(vendorId: number): Promise<PQGroupData[]> { + // 1) Query: pqCriterias + // LEFT JOIN vendorPqCriteriaAnswers (to get `answer`) + // LEFT JOIN vendorCriteriaAttachments (to get each attachment row) + const rows = await db + .select({ + criteriaId: pqCriterias.id, + groupName: pqCriterias.groupName, + code: pqCriterias.code, + checkPoint: pqCriterias.checkPoint, + description: pqCriterias.description, + + // From vendorPqCriteriaAnswers + answer: vendorPqCriteriaAnswers.answer, // can be null if no row exists + answerId: vendorPqCriteriaAnswers.id, // internal PK if needed + + // From vendorCriteriaAttachments + attachId: vendorCriteriaAttachments.id, + fileName: vendorCriteriaAttachments.fileName, + filePath: vendorCriteriaAttachments.filePath, + fileSize: vendorCriteriaAttachments.fileSize, + }) + .from(pqCriterias) + .leftJoin( + vendorPqCriteriaAnswers, + and( + eq(pqCriterias.id, vendorPqCriteriaAnswers.criteriaId), + eq(vendorPqCriteriaAnswers.vendorId, vendorId) + ) + ) + .leftJoin( + vendorCriteriaAttachments, + eq(vendorPqCriteriaAnswers.id, vendorCriteriaAttachments.vendorCriteriaAnswerId) + ) + .orderBy(pqCriterias.groupName, pqCriterias.code) + + // 2) Group by groupName => each group has a map of criteriaId => PQItem + // so we can gather attachments properly. + const groupMap = new Map<string, Record<number, PQItem>>() + + for (const row of rows) { + const g = row.groupName || "Others" + + // Ensure we have an object for this group + if (!groupMap.has(g)) { + groupMap.set(g, {}) + } + + const groupItems = groupMap.get(g)! + // If we haven't seen this criteriaId yet, create a PQItem + if (!groupItems[row.criteriaId]) { + groupItems[row.criteriaId] = { + answerId: row.answerId, + criteriaId: row.criteriaId, + code: row.code, + checkPoint: row.checkPoint, + description: row.description, + answer: row.answer || "", // if row.answer is null, just empty string + attachments: [], + } + } + + // If there's an attachment row (attachId not null), push it onto `attachments` + if (row.attachId) { + groupItems[row.criteriaId].attachments.push({ + attachId: row.attachId, + fileName: row.fileName || "", + filePath: row.filePath || "", + fileSize: row.fileSize || undefined, + }) + } + } + + // 3) Convert groupMap into an array of { groupName, items[] } + const data: PQGroupData[] = [] + for (const [groupName, itemsMap] of groupMap.entries()) { + // Convert the itemsMap (key=criteriaId => PQItem) into an array + const items = Object.values(itemsMap) + data.push({ groupName, items }) + } + + return data +} + + +interface PQAttachmentInput { + fileName: string // original user-friendly file name + url: string // the UUID-based path stored on server + size?: number // optional file size +} + +interface SavePQAnswer { + criteriaId: number + answer: string + attachments: PQAttachmentInput[] +} + +interface SavePQInput { + vendorId: number + answers: SavePQAnswer[] +} + +/** + * 여러 항목을 한 번에 Upsert + */ +export async function savePQAnswersAction(input: SavePQInput) { + const { vendorId, answers } = input + + try { + for (const ans of answers) { + // 1) Check if a row already exists for (vendorId, criteriaId) + const existing = await db + .select() + .from(vendorPqCriteriaAnswers) + .where( + and( + eq(vendorPqCriteriaAnswers.vendorId, vendorId), + eq(vendorPqCriteriaAnswers.criteriaId, ans.criteriaId) + ) + ) + + let answerId: number + + // 2) If it exists, update the row; otherwise insert + if (existing.length === 0) { + // Insert new + const inserted = await db + .insert(vendorPqCriteriaAnswers) + .values({ + vendorId, + criteriaId: ans.criteriaId, + answer: ans.answer, + // no attachmentPaths column anymore + }) + .returning({ id: vendorPqCriteriaAnswers.id }) + + answerId = inserted[0].id + } else { + // Update existing + answerId = existing[0].id + + await db + .update(vendorPqCriteriaAnswers) + .set({ + answer: ans.answer, + updatedAt: new Date(), + }) + .where(eq(vendorPqCriteriaAnswers.id, answerId)) + } + + // 3) Now manage attachments in vendorCriteriaAttachments + // We'll do a "diff": remove old ones not in the new list, insert new ones not in DB. + + // 3a) Load old attachments from DB + const oldAttachments = await db + .select({ + id: vendorCriteriaAttachments.id, + filePath: vendorCriteriaAttachments.filePath, + }) + .from(vendorCriteriaAttachments) + .where(eq(vendorCriteriaAttachments.vendorCriteriaAnswerId, answerId)) + + // 3b) Gather the new filePaths (urls) from the client + const newPaths = ans.attachments.map(a => a.url) + + // 3c) Find attachments to remove + const toRemove = oldAttachments.filter(old => !newPaths.includes(old.filePath)) + if (toRemove.length > 0) { + const removeIds = toRemove.map(r => r.id) + await db + .delete(vendorCriteriaAttachments) + .where(inArray(vendorCriteriaAttachments.id, removeIds)) + } + + // 3d) Insert new attachments that aren’t in DB + const oldPaths = oldAttachments.map(o => o.filePath) + const toAdd = ans.attachments.filter(a => !oldPaths.includes(a.url)) + + for (const attach of toAdd) { + await db.insert(vendorCriteriaAttachments).values({ + vendorCriteriaAnswerId: answerId, + fileName: attach.fileName, // original filename + filePath: attach.url, // random/UUID path on server + fileSize: attach.size ?? null, + // fileType if you have it, etc. + }) + } + } + + return { ok: true } + } catch (error) { + console.error("savePQAnswersAction error:", error) + return { ok: false, error: String(error) } + } +} + + + +/** + * PQ 제출 서버 액션 - 벤더 상태를 PQ_SUBMITTED로 업데이트 + * @param vendorId 벤더 ID + */ +export async function submitPQAction(vendorId: number) { + unstable_noStore(); + + try { + // 1. 모든 PQ 항목에 대한 응답이 있는지 검증 + const pqCriteriaCount = await db + .select({ count: count() }) + .from(vendorPqCriteriaAnswers) + .where(eq(vendorPqCriteriaAnswers.vendorId, vendorId)); + + const totalPqCriteriaCount = pqCriteriaCount[0]?.count || 0; + + // 응답 데이터 검증 + if (totalPqCriteriaCount === 0) { + return { ok: false, error: "No PQ answers found" }; + } + + // 2. 벤더 정보 조회 + const vendor = await db + .select({ + id: vendors.id, + vendorName: vendors.vendorName, + email: vendors.email, + status: vendors.status, + }) + .from(vendors) + .where(eq(vendors.id, vendorId)) + .then(rows => rows[0]); + + if (!vendor) { + return { ok: false, error: "Vendor not found" }; + } + + // 3. 벤더 상태가 제출 가능한 상태인지 확인 + const allowedStatuses = ["IN_PQ", "PENDING_REVIEW", "IN_REVIEW", "REJECTED", "PQ_FAILED"]; + if (!allowedStatuses.includes(vendor.status)) { + return { + ok: false, + error: `Cannot submit PQ in current status: ${vendor.status}` + }; + } + + // 4. 벤더 상태 업데이트 + await db + .update(vendors) + .set({ + status: "PQ_SUBMITTED", + updatedAt: new Date(), + }) + .where(eq(vendors.id, vendorId)); + + // 5. 관리자에게 이메일 알림 발송 + if (process.env.ADMIN_EMAIL) { + try { + await sendEmail({ + to: process.env.ADMIN_EMAIL, + subject: `[eVCP] PQ Submitted: ${vendor.vendorName}`, + template: "pq-submitted-admin", + context: { + vendorName: vendor.vendorName, + vendorId: vendor.id, + submittedDate: new Date().toLocaleString(), + adminUrl: `${process.env.NEXT_PUBLIC_APP_URL}/admin/vendors/${vendorId}/pq`, + } + }); + } catch (emailError) { + console.error("Failed to send admin notification:", emailError); + // 이메일 실패는 전체 프로세스를 중단하지 않음 + } + } + + // 6. 벤더에게 확인 이메일 발송 + if (vendor.email) { + try { + await sendEmail({ + to: vendor.email, + subject: "[eVCP] PQ Submission Confirmation", + template: "pq-submitted-vendor", + context: { + vendorName: vendor.vendorName, + submittedDate: new Date().toLocaleString(), + portalUrl: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard`, + } + }); + } catch (emailError) { + console.error("Failed to send vendor confirmation:", emailError); + // 이메일 실패는 전체 프로세스를 중단하지 않음 + } + } + + // 7. 캐시 무효화 + revalidateTag("vendors"); + revalidateTag("vendor-status-counts"); + + return { ok: true }; + } catch (error) { + console.error("PQ submit error:", error); + return { ok: false, error: getErrorMessage(error) }; + } +} + +/** + * 향상된 파일 업로드 서버 액션 + * - 직접 파일 처리 (file 객체로 받음) + * - 디렉토리 자동 생성 + * - 중복 방지를 위한 UUID 적용 + */ +export async function uploadFileAction(file: File) { + unstable_noStore(); + + try { + // 파일 유효성 검사 + if (!file || file.size === 0) { + throw new Error("Invalid file"); + } + + const maxSize = 6e8; + if (file.size > maxSize) { + throw new Error(`File size exceeds limit (${Math.round(maxSize / 1024 / 1024)}MB)`); + } + + // 파일 확장자 가져오기 + const originalFilename = file.name; + const fileExt = path.extname(originalFilename); + const fileNameWithoutExt = path.basename(originalFilename, fileExt); + + // 저장 경로 설정 + const uploadDir = process.env.UPLOAD_DIR + ? process.env.UPLOAD_DIR + : path.join(process.cwd(), "public", "uploads") + const datePrefix = new Date().toISOString().slice(0, 10).replace(/-/g, ''); // YYYYMMDD + const targetDir = path.join(uploadDir, 'pq', datePrefix); + + // UUID로 고유 파일명 생성 + const uuid = randomUUID(); + const sanitizedFilename = fileNameWithoutExt + .replace(/[^a-zA-Z0-9-_]/g, '_') // 안전한 문자만 허용 + .slice(0, 50); // 이름 길이 제한 + + const filename = `${sanitizedFilename}-${uuid}${fileExt}`; + const filePath = path.join(targetDir, filename); + const relativeFilePath = path.join('pq', datePrefix, filename); + + // 디렉토리 생성 (없는 경우) + try { + await mkdir(targetDir, { recursive: true }); + } catch (err) { + console.error("Error creating directory:", err); + throw new Error("Failed to create upload directory"); + } + + // 파일 저장 + const buffer = await file.arrayBuffer(); + await writeFile(filePath, Buffer.from(buffer)); + + // 상대 경로를 반환 (DB에 저장하기 용이함) + const publicUrl = `/uploads/${relativeFilePath.replace(/\\/g, '/')}`; + + return { + fileName: originalFilename, + url: publicUrl, + size: file.size, + }; + } catch (error) { + console.error("File upload error:", error); + throw new Error(`Upload failed: ${getErrorMessage(error)}`); + } +} + +/** + * 여러 파일 일괄 업로드 + */ +export async function uploadMultipleFilesAction(files: File[]) { + unstable_noStore(); + + try { + const results = []; + + for (const file of files) { + try { + const result = await uploadFileAction(file); + results.push({ + success: true, + ...result + }); + } catch (error) { + results.push({ + success: false, + fileName: file.name, + error: getErrorMessage(error) + }); + } + } + + return { + ok: true, + results + }; + } catch (error) { + console.error("Batch upload error:", error); + return { + ok: false, + error: getErrorMessage(error) + }; + } +} + +export async function getVendorsInPQ(input: GetVendorsSchema) { + return unstable_cache( + async () => { + try { + const offset = (input.page - 1) * input.perPage; + + // 1) 고급 필터 + const advancedWhere = filterColumns({ + table: vendors, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + // 2) 글로벌 검색 + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + ilike(vendors.vendorName, s), + ilike(vendors.vendorCode, s), + ilike(vendors.email, s), + ilike(vendors.status, s) + ); + } + + // 최종 where 결합 + const finalWhere = and(advancedWhere, globalWhere, eq(vendors.status ,"PQ_SUBMITTED")); + + // 간단 검색 (advancedTable=false) 시 예시 + const simpleWhere = and( + input.vendorName + ? ilike(vendors.vendorName, `%${input.vendorName}%`) + : undefined, + input.status ? ilike(vendors.status, input.status) : undefined, + input.country + ? ilike(vendors.country, `%${input.country}%`) + : undefined + ); + + // 실제 사용될 where + const where = finalWhere; + + // 정렬 + const orderBy = + input.sort.length > 0 + ? input.sort.map((item) => + item.desc ? desc(vendors[item.id]) : asc(vendors[item.id]) + ) + : [asc(vendors.createdAt)]; + + // 트랜잭션 내에서 데이터 조회 + const { data, total } = await db.transaction(async (tx) => { + // 1) vendor 목록 조회 + const vendorsData = await selectVendors(tx, { + where, + orderBy, + offset, + limit: input.perPage, + }); + + // 2) 각 vendor의 attachments 조회 + const vendorsWithAttachments = await Promise.all( + vendorsData.map(async (vendor) => { + const attachments = await tx + .select({ + id: vendorAttachments.id, + fileName: vendorAttachments.fileName, + filePath: vendorAttachments.filePath, + }) + .from(vendorAttachments) + .where(eq(vendorAttachments.vendorId, vendor.id)); + + return { + ...vendor, + hasAttachments: attachments.length > 0, + attachmentsList: attachments, + }; + }) + ); + + // 3) 전체 개수 + const total = await countVendors(tx, where); + return { data: vendorsWithAttachments, total }; + }); + + // 페이지 수 + const pageCount = Math.ceil(total / input.perPage); + + return { data, pageCount }; + } catch (err) { + // 에러 발생 시 + return { data: [], pageCount: 0 }; + } + }, + [JSON.stringify(input)], // 캐싱 키 + { + revalidate: 3600, + tags: ["vendors-in-pq"], // revalidateTag("vendors") 호출 시 무효화 + } + )(); +} + + +export type VendorStatus = + | "PENDING_REVIEW" + | "IN_REVIEW" + | "REJECTED" + | "IN_PQ" + | "PQ_SUBMITTED" + | "PQ_FAILED" + | "APPROVED" + | "ACTIVE" + | "INACTIVE" + | "BLACKLISTED" + + export async function updateVendorStatusAction( + vendorId: number, + newStatus: VendorStatus + ) { + try { + // 1) Update DB + await db.update(vendors) + .set({ status: newStatus }) + .where(eq(vendors.id, vendorId)) + + // 2) Load vendor’s email & name + const vendor = await db.select().from(vendors).where(eq(vendors.id, vendorId)).then(r => r[0]) + if (!vendor) { + return { ok: false, error: "Vendor not found" } + } + + // 3) Send email + await sendEmail({ + to: vendor.email || "", + subject: `Your PQ Status is now ${newStatus}`, + template: "vendor-pq-status", // matches .hbs file + context: { + name: vendor.vendorName, + status: newStatus, + loginUrl: `${process.env.NEXT_PUBLIC_URL}/partners/pq`, // etc. + }, + }) + revalidateTag("vendors") + revalidateTag("vendors-in-pq") + return { ok: true } + } catch (error) { + console.error("updateVendorStatusAction error:", error) + return { ok: false, error: String(error) } + } + } +// 코멘트 타입 정의 +interface ItemComment { + answerId: number; + checkPoint: string; // 체크포인트 정보 추가 + code: string; // 코드 정보 추가 + comment: string; +} + +/** + * PQ 변경 요청 처리 서버 액션 + * + * @param vendorId 벤더 ID + * @param comment 항목별 코멘트 배열 (answerId, checkPoint, code, comment로 구성) + * @param generalComment 전체 PQ에 대한 일반 코멘트 (선택사항) + */ +export async function requestPqChangesAction({ + vendorId, + comment, + generalComment, +}: { + vendorId: number; + comment: ItemComment[]; + generalComment?: string; +}) { + try { + // 1) 벤더 상태 업데이트 + await db.update(vendors) + .set({ + status: "IN_PQ", // 변경 요청 상태로 설정 + updatedAt: new Date(), + }) + .where(eq(vendors.id, vendorId)); + + // 2) 벤더 정보 가져오기 + const vendor = await db.select() + .from(vendors) + .where(eq(vendors.id, vendorId)) + .then(r => r[0]); + + if (!vendor) { + return { ok: false, error: "Vendor not found" }; + } + + // 3) 각 항목별 코멘트 저장 + const currentDate = new Date(); + const reviewerId = 1; // 관리자 ID (실제 구현에서는 세션에서 가져옵니다) + const reviewerName = "AdminUser"; // 실제 구현에서는 세션에서 가져옵니다 + + // 병렬로 모든 코멘트 저장 + if (comment && comment.length > 0) { + const insertPromises = comment.map(item => + db.insert(vendorPqReviewLogs) + .values({ + vendorPqCriteriaAnswerId: item.answerId, + // reviewerId: reviewerId, + reviewerName: reviewerName, + reviewerComment: item.comment, + createdAt: currentDate, + // 추가 메타데이터 필드가 있다면 저장 + // 이런 메타데이터는 DB 스키마에 해당 필드가 있어야 함 + // meta: JSON.stringify({ checkPoint: item.checkPoint, code: item.code }) + }) + ); + + // 모든 삽입 기다리기 + await Promise.all(insertPromises); + } + + // 4) 변경 요청 이메일 보내기 + // 코멘트 목록 준비 + const commentItems = comment.map(item => ({ + id: item.answerId, + code: item.code, + checkPoint: item.checkPoint, + text: item.comment + })); + + await sendEmail({ + to: vendor.email || "", + subject: `[IMPORTANT] Your PQ submission requires changes`, + template: "vendor-pq-comment", // matches .hbs file + context: { + name: vendor.vendorName, + vendorCode: vendor.vendorCode, + loginUrl: `${process.env.NEXT_PUBLIC_URL}/partners/pq`, + comments: commentItems, + generalComment: generalComment || "", + hasGeneralComment: !!generalComment, + commentCount: commentItems.length, + }, + }); + + revalidateTag("vendors") + revalidateTag("vendors-in-pq") + + return { ok: true }; + } catch (error) { + console.error("requestPqChangesAction error:", error); + return { ok: false, error: String(error) }; + } +} +interface AddReviewCommentInput { + answerId: number // vendorPqCriteriaAnswers.id + comment: string + reviewerName?: string +} + +export async function addReviewCommentAction(input: AddReviewCommentInput) { + try { + // 1) Check that the answer row actually exists + const existing = await db + .select({ id: vendorPqCriteriaAnswers.id }) + .from(vendorPqCriteriaAnswers) + .where(eq(vendorPqCriteriaAnswers.id, input.answerId)) + + if (existing.length === 0) { + return { ok: false, error: "Item not found" } + } + + // 2) Insert the log + await db.insert(vendorPqReviewLogs).values({ + vendorPqCriteriaAnswerId: input.answerId, + reviewerComment: input.comment, + reviewerName: input.reviewerName ?? "AdminUser", + }) + + return { ok: true } + } catch (error) { + console.error("addReviewCommentAction error:", error) + return { ok: false, error: String(error) } + } +} + +interface GetItemReviewLogsInput { + answerId: number +} + +export async function getItemReviewLogsAction(input: GetItemReviewLogsInput) { + try { + + const logs = await db + .select() + .from(vendorPqReviewLogs) + .where(eq(vendorPqReviewLogs.vendorPqCriteriaAnswerId, input.answerId)) + .orderBy(desc(vendorPqReviewLogs.createdAt)); + + return { ok: true, data: logs }; + } catch (error) { + console.error("getItemReviewLogsAction error:", error); + return { ok: false, error: String(error) }; + } +}
\ No newline at end of file diff --git a/lib/pq/table/add-pq-dialog.tsx b/lib/pq/table/add-pq-dialog.tsx new file mode 100644 index 00000000..8164dbaf --- /dev/null +++ b/lib/pq/table/add-pq-dialog.tsx @@ -0,0 +1,299 @@ +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { z } from "zod" +import { Plus } from "lucide-react" +import { useRouter } from "next/navigation" + +import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { useToast } from "@/hooks/use-toast" +import { createPq, invalidatePqCache } from "../service" + +// PQ 생성을 위한 Zod 스키마 정의 +const createPqSchema = z.object({ + code: z.string().min(1, "Code is required"), + checkPoint: z.string().min(1, "Check point is required"), + groupName: z.string().min(1, "Group is required"), + description: z.string().optional(), + remarks: z.string().optional() +}); + +type CreatePqInputType = z.infer<typeof createPqSchema>; + +// 그룹 이름 옵션 +const groupOptions = [ + "GENERAL", + "Quality Management System", + "Workshop & Environment", + "Warranty", +]; + +// 설명 예시 텍스트 +const descriptionExample = `Address : +Tel. / Fax : +e-mail :`; + +export function AddPqDialog() { + const [open, setOpen] = React.useState(false) + const [isSubmitting, setIsSubmitting] = React.useState(false) + const router = useRouter() + const { toast } = useToast() + + // react-hook-form 설정 + const form = useForm<CreatePqInputType>({ + resolver: zodResolver(createPqSchema), + defaultValues: { + code: "", + checkPoint: "", + groupName: groupOptions[0], + description: "", + remarks: "" + }, + }) + + // 예시 텍스트를 description 필드에 채우는 함수 + const fillExampleText = () => { + form.setValue("description", descriptionExample); + }; + + async function onSubmit(data: CreatePqInputType) { + try { + setIsSubmitting(true) + + // 서버 액션 호출 + const result = await createPq(data) + + if (!result.success) { + toast({ + title: "Error", + description: result.message || "Failed to create PQ criteria", + variant: "destructive", + }) + return + } + + await invalidatePqCache(); + + // 성공 시 처리 + toast({ + title: "Success", + description: "PQ criteria created successfully", + }) + + // 모달 닫고 폼 리셋 + form.reset() + setOpen(false) + + // 페이지 새로고침 + router.refresh() + + } catch (error) { + console.error('Error creating PQ criteria:', error) + toast({ + title: "Error", + description: "An unexpected error occurred", + variant: "destructive", + }) + } finally { + setIsSubmitting(false) + } + } + + function handleDialogOpenChange(nextOpen: boolean) { + if (!nextOpen) { + form.reset() + } + setOpen(nextOpen) + } + + return ( + <Dialog open={open} onOpenChange={handleDialogOpenChange}> + {/* 모달을 열기 위한 버튼 */} + <DialogTrigger asChild> + <Button variant="default" size="sm"> + <Plus className="size-4" /> + Add PQ + </Button> + </DialogTrigger> + + <DialogContent className="sm:max-w-[550px]"> + <DialogHeader> + <DialogTitle>Create New PQ Criteria</DialogTitle> + <DialogDescription> + 새 PQ 기준 정보를 입력하고 <b>Create</b> 버튼을 누르세요. + </DialogDescription> + </DialogHeader> + + {/* shadcn/ui Form을 이용해 react-hook-form과 연결 */} + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4 py-2"> + {/* Code 필드 */} + <FormField + control={form.control} + name="code" + render={({ field }) => ( + <FormItem> + <FormLabel>Code <span className="text-destructive">*</span></FormLabel> + <FormControl> + <Input + placeholder="예: 1-1, A.2.3" + {...field} + /> + </FormControl> + <FormDescription> + PQ 항목의 고유 코드를 입력하세요 (예: "1-1", "A.2.3") + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + {/* Check Point 필드 */} + <FormField + control={form.control} + name="checkPoint" + render={({ field }) => ( + <FormItem> + <FormLabel>Check Point <span className="text-destructive">*</span></FormLabel> + <FormControl> + <Input + placeholder="검증 항목을 입력하세요" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Group Name 필드 (Select) */} + <FormField + control={form.control} + name="groupName" + render={({ field }) => ( + <FormItem> + <FormLabel>Group <span className="text-destructive">*</span></FormLabel> + <Select + onValueChange={field.onChange} + defaultValue={field.value} + value={field.value} + > + <FormControl> + <SelectTrigger> + <SelectValue placeholder="그룹을 선택하세요" /> + </SelectTrigger> + </FormControl> + <SelectContent> + {groupOptions.map((group) => ( + <SelectItem key={group} value={group}> + {group} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormDescription> + PQ 항목의 분류 그룹을 선택하세요 + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + {/* Description 필드 - 예시 템플릿 버튼 추가 */} + <FormField + control={form.control} + name="description" + render={({ field }) => ( + <FormItem> + <div className="flex items-center justify-between"> + <FormLabel>Description</FormLabel> + <Button + type="button" + variant="outline" + size="sm" + onClick={fillExampleText} + > + 예시 채우기 + </Button> + </div> + <FormControl> + <Textarea + placeholder={`줄바꿈을 포함한 상세 설명을 입력하세요\n예:\n${descriptionExample}`} + className="min-h-[120px] font-mono" + {...field} + value={field.value || ""} + /> + </FormControl> + <FormDescription> + 줄바꿈이 필요한 경우 Enter 키를 누르세요. 입력한 대로 저장됩니다. + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + {/* Remarks 필드 */} + <FormField + control={form.control} + name="remarks" + render={({ field }) => ( + <FormItem> + <FormLabel>Remarks</FormLabel> + <FormControl> + <Textarea + placeholder="비고 사항을 입력하세요" + className="min-h-[80px]" + {...field} + value={field.value || ""} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => { + form.reset(); + setOpen(false); + }} + > + Cancel + </Button> + <Button + type="submit" + disabled={isSubmitting} + > + {isSubmitting ? "Creating..." : "Create"} + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/pq/table/delete-pqs-dialog.tsx b/lib/pq/table/delete-pqs-dialog.tsx new file mode 100644 index 00000000..c6a2ce82 --- /dev/null +++ b/lib/pq/table/delete-pqs-dialog.tsx @@ -0,0 +1,149 @@ +"use client" + +import * as React from "react" +import { type Row } from "@tanstack/react-table" +import { Loader, Trash } from "lucide-react" +import { toast } from "sonner" + +import { useMediaQuery } from "@/hooks/use-media-query" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer" +import { PqCriterias } from "@/db/schema/pq" +import { removePqs } from "../service" + + +interface DeleteTasksDialogProps + extends React.ComponentPropsWithoutRef<typeof Dialog> { + pqs: Row<PqCriterias>["original"][] + showTrigger?: boolean + onSuccess?: () => void +} + +export function DeletePqsDialog({ + pqs, + showTrigger = true, + onSuccess, + ...props +}: DeleteTasksDialogProps) { + const [isDeletePending, startDeleteTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + + function onDelete() { + startDeleteTransition(async () => { + const { error } = await removePqs({ + ids: pqs.map((pq) => pq.id), + }) + + if (error) { + toast.error(error) + return + } + + props.onOpenChange?.(false) + toast.success("Tasks deleted") + onSuccess?.() + }) + } + + if (isDesktop) { + return ( + <Dialog {...props}> + {showTrigger ? ( + <DialogTrigger asChild> + <Button variant="outline" size="sm"> + <Trash className="mr-2 size-4" aria-hidden="true" /> + Delete ({pqs.length}) + </Button> + </DialogTrigger> + ) : null} + <DialogContent> + <DialogHeader> + <DialogTitle>Are you absolutely sure?</DialogTitle> + <DialogDescription> + This action cannot be undone. This will permanently delete your{" "} + <span className="font-medium">{pqs.length}</span> + {pqs.length === 1 ? " PQ" : " PQs"} from our servers. + </DialogDescription> + </DialogHeader> + <DialogFooter className="gap-2 sm:space-x-0"> + <DialogClose asChild> + <Button variant="outline">Cancel</Button> + </DialogClose> + <Button + aria-label="Delete selected rows" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending} + > + {isDeletePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + Delete + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) + } + + return ( + <Drawer {...props}> + {showTrigger ? ( + <DrawerTrigger asChild> + <Button variant="outline" size="sm"> + <Trash className="mr-2 size-4" aria-hidden="true" /> + Delete ({pqs.length}) + </Button> + </DrawerTrigger> + ) : null} + <DrawerContent> + <DrawerHeader> + <DrawerTitle>Are you absolutely sure?</DrawerTitle> + <DrawerDescription> + This action cannot be undone. This will permanently delete your{" "} + <span className="font-medium">{pqs.length}</span> + {pqs.length === 1 ? " task" : " pqs"} from our servers. + </DrawerDescription> + </DrawerHeader> + <DrawerFooter className="gap-2 sm:space-x-0"> + <DrawerClose asChild> + <Button variant="outline">Cancel</Button> + </DrawerClose> + <Button + aria-label="Delete selected rows" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending} + > + {isDeletePending && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + Delete + </Button> + </DrawerFooter> + </DrawerContent> + </Drawer> + ) +} diff --git a/lib/pq/table/pq-table-column.tsx b/lib/pq/table/pq-table-column.tsx new file mode 100644 index 00000000..7efed645 --- /dev/null +++ b/lib/pq/table/pq-table-column.tsx @@ -0,0 +1,185 @@ +"use client" + +import * as React from "react" +import { ColumnDef } from "@tanstack/react-table" +import { formatDate, formatDateTime } from "@/lib/utils" +import { Checkbox } from "@/components/ui/checkbox" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { DataTableRowAction } from "@/types/table" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Button } from "@/components/ui/button" +import { Ellipsis } from "lucide-react" +import { Badge } from "@/components/ui/badge" +import { PqCriterias } from "@/db/schema/pq" + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<PqCriterias> | null>> +} + +export function getColumns({ + setRowAction, +}: GetColumnsProps): ColumnDef<PqCriterias>[] { + return [ + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + size:40, + enableSorting: false, + enableHiding: false, + }, + + { + accessorKey: "groupName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Group Name" /> + ), + cell: ({ row }) => <div>{row.getValue("groupName")}</div>, + meta: { + excelHeader: "Group Name" + }, + enableResizing: true, + minSize: 60, + size: 100, + }, + { + accessorKey: "code", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Code" /> + ), + cell: ({ row }) => <div>{row.getValue("code")}</div>, + meta: { + excelHeader: "Code" + }, + enableResizing: true, + minSize: 50, + size: 100, + }, + { + accessorKey: "checkPoint", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Check Point" /> + ), + cell: ({ row }) => <div>{row.getValue("checkPoint")}</div>, + meta: { + excelHeader: "Check Point" + }, + enableResizing: true, + minSize: 180, + size: 180, + }, + + { + accessorKey: "description", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Description" /> + ), + cell: ({ row }) => { + const text = row.getValue("description") as string + return ( + <div style={{ whiteSpace: "pre-wrap" }}> + {text} + </div> + ) + }, + meta: { + excelHeader: "Description" + }, + enableResizing: true, + minSize: 180, + size: 180, + }, + + { + accessorKey: "createdAt", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Created At" /> + ), + cell: ({ cell }) => formatDateTime(cell.getValue() as Date), + meta: { + excelHeader: "created At" + }, + enableResizing: true, + minSize: 180, + size: 180, + }, + { + accessorKey: "updatedAt", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Updated At" /> + ), + cell: ({ cell }) => formatDateTime(cell.getValue() as Date), + meta: { + excelHeader: "updated At" + }, + enableResizing: true, + minSize: 180, + size: 180, + }, + { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-7 p-0 data-[state=open]:bg-muted" + > + <Ellipsis className="size-4" aria-hidden="true" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-40"> + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "update" })} + > + Edit + </DropdownMenuItem> + + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "delete" })} + > + Delete + <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ) + }, + size: 40, + } + ] +}
\ No newline at end of file diff --git a/lib/pq/table/pq-table-toolbar-actions.tsx b/lib/pq/table/pq-table-toolbar-actions.tsx new file mode 100644 index 00000000..1d151520 --- /dev/null +++ b/lib/pq/table/pq-table-toolbar-actions.tsx @@ -0,0 +1,55 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Download, Send, Upload } from "lucide-react" +import { toast } from "sonner" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" +import { DeletePqsDialog } from "./delete-pqs-dialog" +import { AddPqDialog } from "./add-pq-dialog" +import { PqCriterias } from "@/db/schema/pq" + + +interface DocTableToolbarActionsProps { + table: Table<PqCriterias> +} + +export function PqTableToolbarActions({ table}: DocTableToolbarActionsProps) { + + + return ( + <div className="flex items-center gap-2"> + {table.getFilteredSelectedRowModel().rows.length > 0 ? ( + <DeletePqsDialog + pqs={table + .getFilteredSelectedRowModel() + .rows.map((row) => row.original)} + onSuccess={() => table.toggleAllRowsSelected(false)} + /> + ) : null} + + + <AddPqDialog /> + + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(table, { + filename: "Document-list", + excludeColumns: ["select", "actions"], + }) + } + className="gap-2" + > + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Export</span> + </Button> + + + + </div> + ) +}
\ No newline at end of file diff --git a/lib/pq/table/pq-table.tsx b/lib/pq/table/pq-table.tsx new file mode 100644 index 00000000..73876c72 --- /dev/null +++ b/lib/pq/table/pq-table.tsx @@ -0,0 +1,125 @@ +"use client" + +import * as React from "react" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" + +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { getPQs } from "../service" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import { PqCriterias } from "@/db/schema/pq" +import { DeletePqsDialog } from "./delete-pqs-dialog" +import { PqTableToolbarActions } from "./pq-table-toolbar-actions" +import { getColumns } from "./pq-table-column" +import { UpdatePqSheet } from "./update-pq-sheet" + +interface DocumentListTableProps { + promises: Promise<[Awaited<ReturnType<typeof getPQs>>]> +} + +export function PqsTable({ + promises, +}: DocumentListTableProps) { + // 1) 데이터를 가져옴 (server component -> use(...) pattern) + const [{ data, pageCount }] = React.use(promises) + + const [rowAction, setRowAction] = React.useState<DataTableRowAction<PqCriterias> | null>(null) + + + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + // Filter fields + const filterFields: DataTableFilterField<PqCriterias>[] = [] + + const advancedFilterFields: DataTableAdvancedFilterField<PqCriterias>[] = [ + { + id: "code", + label: "Code", + type: "text", + }, + { + id: "checkPoint", + label: "Check Point", + type: "text", + }, + { + id: "description", + label: "Description", + type: "text", + }, + { + id: "remarks", + label: "Remarks", + type: "text", + }, + { + id: "groupName", + label: "Group Name", + type: "text", + }, + { + id: "createdAt", + label: "Created at", + type: "date", + }, + { + id: "updatedAt", + label: "Updated at", + type: "date", + }, + ] + + // useDataTable 훅으로 react-table 구성 + const { table } = useDataTable({ + data: data, // <-- 여기서 tableData 사용 + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + // grouping:['groupName'] + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + columnResizeMode: "onEnd", + + }) + return ( + <> + <DataTable table={table} > + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <PqTableToolbarActions table={table} /> + </DataTableAdvancedToolbar> + </DataTable> + + <UpdatePqSheet + open={rowAction?.type === "update"} + onOpenChange={() => setRowAction(null)} + pq={rowAction?.row.original ?? null} + /> + + <DeletePqsDialog + open={rowAction?.type === "delete"} + onOpenChange={() => setRowAction(null)} + pqs={rowAction?.row.original ? [rowAction?.row.original] : []} + showTrigger={false} + onSuccess={() => rowAction?.row.toggleSelected(false)} + /> + </> + ) +}
\ No newline at end of file diff --git a/lib/pq/table/update-pq-sheet.tsx b/lib/pq/table/update-pq-sheet.tsx new file mode 100644 index 00000000..3bac3558 --- /dev/null +++ b/lib/pq/table/update-pq-sheet.tsx @@ -0,0 +1,272 @@ +"use client" + +import * as React from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { Loader, Save } from "lucide-react" +import { useForm } from "react-hook-form" +import { toast } from "sonner" +import { z } from "zod" +import { useRouter } from "next/navigation" + +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" + +import { modifyPq } from "../service" + +// PQ 수정을 위한 Zod 스키마 정의 +const updatePqSchema = z.object({ + code: z.string().min(1, "Code is required"), + checkPoint: z.string().min(1, "Check point is required"), + groupName: z.string().min(1, "Group is required"), + description: z.string().optional(), + remarks: z.string().optional() +}); + +type UpdatePqSchema = z.infer<typeof updatePqSchema>; + +// 그룹 이름 옵션 +const groupOptions = [ + "GENERAL", + "Quality Management System", + "Organization", + "Resource Management", + "Other" +]; + +interface UpdatePqSheetProps + extends React.ComponentPropsWithRef<typeof Sheet> { + pq: { + id: number; + code: string; + checkPoint: string; + description: string | null; + remarks: string | null; + groupName: string | null; + } | null +} + +export function UpdatePqSheet({ pq, ...props }: UpdatePqSheetProps) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + const router = useRouter() + + const form = useForm<UpdatePqSchema>({ + resolver: zodResolver(updatePqSchema), + defaultValues: { + code: pq?.code ?? "", + checkPoint: pq?.checkPoint ?? "", + groupName: pq?.groupName ?? groupOptions[0], + description: pq?.description ?? "", + remarks: pq?.remarks ?? "", + }, + }) + + // 폼 초기화 (pq가 변경될 때) + React.useEffect(() => { + if (pq) { + form.reset({ + code: pq.code, + checkPoint: pq.checkPoint, + groupName: pq.groupName ?? groupOptions[0], + description: pq.description ?? "", + remarks: pq.remarks ?? "", + }); + } + }, [pq, form]); + + function onSubmit(input: UpdatePqSchema) { + startUpdateTransition(async () => { + if (!pq) return + + const result = await modifyPq({ + id: pq.id, + ...input, + }) + + if (!result.success && 'error' in result) { + toast.error(result.error) + } else { + toast.error("Failed to update PQ criteria") + } + + form.reset() + props.onOpenChange?.(false) + toast.success("PQ criteria updated successfully") + router.refresh() + }) + } + return ( + <Sheet {...props}> + <SheetContent className="flex flex-col gap-6 sm:max-w-md"> + <SheetHeader className="text-left"> + <SheetTitle>Update PQ Criteria</SheetTitle> + <SheetDescription> + Update the PQ criteria details and save the changes + </SheetDescription> + </SheetHeader> + <Form {...form}> + <form + onSubmit={form.handleSubmit(onSubmit)} + className="flex flex-col gap-4" + > + {/* Code 필드 */} + <FormField + control={form.control} + name="code" + render={({ field }) => ( + <FormItem> + <FormLabel>Code <span className="text-destructive">*</span></FormLabel> + <FormControl> + <Input + placeholder="예: 1-1, A.2.3" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Check Point 필드 */} + <FormField + control={form.control} + name="checkPoint" + render={({ field }) => ( + <FormItem> + <FormLabel>Check Point <span className="text-destructive">*</span></FormLabel> + <FormControl> + <Input + placeholder="검증 항목을 입력하세요" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Group Name 필드 (Select) */} + <FormField + control={form.control} + name="groupName" + render={({ field }) => ( + <FormItem> + <FormLabel>Group <span className="text-destructive">*</span></FormLabel> + <Select + onValueChange={field.onChange} + defaultValue={field.value} + value={field.value} + > + <FormControl> + <SelectTrigger> + <SelectValue placeholder="그룹을 선택하세요" /> + </SelectTrigger> + </FormControl> + <SelectContent> + {groupOptions.map((group) => ( + <SelectItem key={group} value={group}> + {group} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + {/* Description 필드 */} + <FormField + control={form.control} + name="description" + render={({ field }) => ( + <FormItem> + <FormLabel>Description</FormLabel> + <FormControl> + <Textarea + placeholder="상세 설명을 입력하세요" + className="min-h-[120px] whitespace-pre-wrap" + {...field} + value={field.value || ""} + /> + </FormControl> + <FormDescription> + 줄바꿈이 필요한 경우 Enter 키를 누르세요. 입력한 대로 저장됩니다. + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + {/* Remarks 필드 */} + <FormField + control={form.control} + name="remarks" + render={({ field }) => ( + <FormItem> + <FormLabel>Remarks</FormLabel> + <FormControl> + <Textarea + placeholder="비고 사항을 입력하세요" + className="min-h-[80px]" + {...field} + value={field.value || ""} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <SheetFooter className="gap-2 pt-2 sm:space-x-0"> + <SheetClose asChild> + <Button + type="button" + variant="outline" + onClick={() => form.reset()} + > + Cancel + </Button> + </SheetClose> + <Button disabled={isUpdatePending}> + {isUpdatePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + <Save className="mr-2 size-4" /> Save + </Button> + </SheetFooter> + </form> + </Form> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/lib/pq/validations.ts b/lib/pq/validations.ts new file mode 100644 index 00000000..27e065ba --- /dev/null +++ b/lib/pq/validations.ts @@ -0,0 +1,36 @@ +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, +} from "nuqs/server" +import * as z from "zod" + +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" +import { PqCriterias } from "@/db/schema/pq" + +export const searchParamsCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault( + [] + ), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser<PqCriterias>().withDefault([ + { id: "createdAt", desc: true }, + ]), + code: parseAsString.withDefault(""), + checkPoint: parseAsString.withDefault(""), + description: parseAsString.withDefault(""), + remarks: parseAsString.withDefault(""), + groupName: parseAsString.withDefault(""), + + // advanced filter + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), + +}) + + +export type GetPQSchema = Awaited<ReturnType<typeof searchParamsCache.parse>> diff --git a/lib/rfqs/cbe-table/cbe-table-columns.tsx b/lib/rfqs/cbe-table/cbe-table-columns.tsx new file mode 100644 index 00000000..325b0465 --- /dev/null +++ b/lib/rfqs/cbe-table/cbe-table-columns.tsx @@ -0,0 +1,227 @@ +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { Download, Ellipsis, MessageSquare } from "lucide-react" +import { toast } from "sonner" + +import { getErrorMessage } from "@/lib/handle-error" +import { formatDate } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { useRouter } from "next/navigation" + + +import { VendorWithCbeFields,vendorCbeColumnsConfig } from "@/config/vendorCbeColumnsConfig" + +type NextRouter = ReturnType<typeof useRouter> + +interface GetColumnsProps { + setRowAction: React.Dispatch< + React.SetStateAction<DataTableRowAction<VendorWithCbeFields> | null> + > + router: NextRouter + openCommentSheet: (vendorId: number) => void + openFilesDialog: (cbeId:number , vendorId: number) => void +} + +/** + * tanstack table 컬럼 정의 (중첩 헤더 버전) + */ +export function getColumns({ + setRowAction, + router, + openCommentSheet, + openFilesDialog +}: GetColumnsProps): ColumnDef<VendorWithCbeFields>[] { + // ---------------------------------------------------------------- + // 1) Select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef<VendorWithCbeFields> = { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + } + + // ---------------------------------------------------------------- + // 2) 그룹화(Nested) 컬럼 구성 + // ---------------------------------------------------------------- + const groupMap: Record<string, ColumnDef<VendorWithCbeFields>[]> = {} + + vendorCbeColumnsConfig.forEach((cfg) => { + const groupName = cfg.group || "_noGroup" + if (!groupMap[groupName]) { + groupMap[groupName] = [] + } + + // childCol: ColumnDef<VendorWithCbeFields> + const childCol: ColumnDef<VendorWithCbeFields> = { + accessorKey: cfg.id, + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title={cfg.label} /> + ), + meta: { + excelHeader: cfg.excelHeader, + group: cfg.group, + type: cfg.type, + }, + // 셀 렌더링 + cell: ({ row, getValue }) => { + // 1) 필드값 가져오기 + const val = getValue() + + if (cfg.id === "vendorStatus") { + const statusVal = row.original.vendorStatus + if (!statusVal) return null + // const Icon = getStatusIcon(statusVal) + return ( + <Badge variant="outline"> + {statusVal} + </Badge> + ) + } + + + if (cfg.id === "rfqVendorStatus") { + const statusVal = row.original.rfqVendorStatus + if (!statusVal) return null + // const Icon = getStatusIcon(statusVal) + const variant = statusVal ==="INVITED"?"default" :statusVal ==="DECLINED"?"destructive":statusVal ==="ACCEPTED"?"secondary":"outline" + return ( + <Badge variant={variant}> + {statusVal} + </Badge> + ) + } + + // 예) TBE Updated (날짜) + if (cfg.id === "cbeUpdated") { + const dateVal = val as Date | undefined + if (!dateVal) return null + return formatDate(dateVal) + } + + // 그 외 필드는 기본 값 표시 + return val ?? "" + }, + } + + groupMap[groupName].push(childCol) + }) + + // groupMap → nestedColumns + const nestedColumns: ColumnDef<VendorWithCbeFields>[] = [] + Object.entries(groupMap).forEach(([groupName, colDefs]) => { + if (groupName === "_noGroup") { + nestedColumns.push(...colDefs) + } else { + nestedColumns.push({ + id: groupName, + header: groupName, + columns: colDefs, + }) + } + }) + +// ---------------------------------------------------------------- +// 3) Comments 컬럼 +// ---------------------------------------------------------------- +const commentsColumn: ColumnDef<VendorWithCbeFields> = { + id: "comments", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Comments" /> + ), + cell: ({ row }) => { + const vendor = row.original + const commCount = vendor.comments?.length ?? 0 + + function handleClick() { + // rowAction + openCommentSheet + setRowAction({ row, type: "comments" }) + openCommentSheet(vendor.cbeId ?? 0) + } + + return ( + <div className="flex items-center justify-center"> + <Button + variant="ghost" + size="sm" + className="h-8 w-8 p-0 group relative" + onClick={handleClick} + aria-label={commCount > 0 ? `View ${commCount} comments` : "Add comment"} + > + <div className="flex items-center justify-center relative"> + {commCount > 0 ? ( + <> + <MessageSquare className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> + <Badge + variant="secondary" + className="absolute -top-2 -right-2 h-4 min-w-4 text-xs px-1 flex items-center justify-center" + > + {commCount} + </Badge> + </> + ) : ( + <MessageSquare className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> + )} + </div> + <span className="sr-only">{commCount > 0 ? `${commCount} Comments` : "Add Comment"}</span> + </Button> + {/* <span className="ml-2 text-sm text-muted-foreground hover:text-foreground transition-colors cursor-pointer" onClick={handleClick}> + {commCount > 0 ? `${commCount} Comments` : "Add Comment"} + </span> */} + </div> + ) + }, + enableSorting: false, + maxSize:80 +} + + + + +// ---------------------------------------------------------------- +// 5) 최종 컬럼 배열 - Update to include the files column +// ---------------------------------------------------------------- +return [ + selectColumn, + ...nestedColumns, + commentsColumn, + // actionsColumn, +] + +}
\ No newline at end of file diff --git a/lib/rfqs/cbe-table/cbe-table.tsx b/lib/rfqs/cbe-table/cbe-table.tsx new file mode 100644 index 00000000..243b91ed --- /dev/null +++ b/lib/rfqs/cbe-table/cbe-table.tsx @@ -0,0 +1,161 @@ +"use client" + +import * as React from "react" +import { useRouter } from "next/navigation" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" + +import { toSentenceCase } from "@/lib/utils" +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import { useFeatureFlags } from "./feature-flags-provider" +import { Vendor, vendors } from "@/db/schema/vendors" +import { fetchRfqAttachmentsbyCommentId, getCBE } from "../service" +import { TbeComment } from "../tbe-table/comments-sheet" +import { getColumns } from "./cbe-table-columns" +import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig" + +interface VendorsTableProps { + promises: Promise< + [ + Awaited<ReturnType<typeof getCBE>>, + ] + > + rfqId: number +} + + +export function CbeTable({ promises, rfqId }: VendorsTableProps) { + const { featureFlags } = useFeatureFlags() + + // Suspense로 받아온 데이터 + const [{ data, pageCount }] = React.use(promises) + + console.log(data, "data") + + const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorWithCbeFields> | null>(null) + + // **router** 획득 + const router = useRouter() + + const [initialComments, setInitialComments] = React.useState<TbeComment[]>([]) + const [commentSheetOpen, setCommentSheetOpen] = React.useState(false) + const [selectedRfqIdForComments, setSelectedRfqIdForComments] = React.useState<number | null>(null) + + const [isFileDialogOpen, setIsFileDialogOpen] = React.useState(false) + const [selectedVendorId, setSelectedVendorId] = React.useState<number | null>(null) + const [selectedTbeId, setSelectedTbeId] = React.useState<number | null>(null) + + // Add handleRefresh function + const handleRefresh = React.useCallback(() => { + router.refresh(); + }, [router]); + + React.useEffect(() => { + if (rowAction?.type === "comments") { + // rowAction가 새로 세팅된 뒤 여기서 openCommentSheet 실행 + openCommentSheet(Number(rowAction.row.original.id)) + } else if (rowAction?.type === "files") { + // Handle files action + const vendorId = rowAction.row.original.vendorId; + const cbeId = rowAction.row.original.cbeId ?? 0; + openFilesDialog(cbeId, vendorId); + } + }, [rowAction]) + + async function openCommentSheet(vendorId: number) { + setInitialComments([]) + + const comments = rowAction?.row.original.comments + + if (comments && comments.length > 0) { + const commentWithAttachments: TbeComment[] = await Promise.all( + comments.map(async (c) => { + const attachments = await fetchRfqAttachmentsbyCommentId(c.id) + + return { + ...c, + commentedBy: 1, // DB나 API 응답에 있다고 가정 + attachments, + } + }) + ) + // 3) state에 저장 -> CommentSheet에서 initialComments로 사용 + setInitialComments(commentWithAttachments) + } + + setSelectedRfqIdForComments(vendorId) + setCommentSheetOpen(true) + } + + const openFilesDialog = (cbeId: number, vendorId: number) => { + setSelectedTbeId(cbeId) + setSelectedVendorId(vendorId) + setIsFileDialogOpen(true) + } + + + // getColumns() 호출 시, router를 주입 + const columns = React.useMemo( + () => getColumns({ setRowAction, router, openCommentSheet, openFilesDialog }), + [setRowAction, router] + ) + + const filterFields: DataTableFilterField<VendorWithCbeFields>[] = [ + ] + + const advancedFilterFields: DataTableAdvancedFilterField<VendorWithCbeFields>[] = [ + { id: "vendorName", label: "Vendor Name", type: "text" }, + { id: "vendorCode", label: "Vendor Code", type: "text" }, + { id: "email", label: "Email", type: "text" }, + { id: "country", label: "Country", type: "text" }, + { + id: "vendorStatus", + label: "Vendor Status", + type: "multi-select", + options: vendors.status.enumValues.map((status) => ({ + label: toSentenceCase(status), + value: status, + })), + }, + { id: "rfqVendorUpdated", label: "Updated at", type: "date" }, + ] + + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "rfqVendorUpdated", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + <DataTable + table={table} + > + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + {/* <VendorsTableToolbarActions table={table} rfqId={rfqId} /> */} + </DataTableAdvancedToolbar> + </DataTable> + + </> + ) +}
\ No newline at end of file diff --git a/lib/rfqs/cbe-table/feature-flags-provider.tsx b/lib/rfqs/cbe-table/feature-flags-provider.tsx new file mode 100644 index 00000000..81131894 --- /dev/null +++ b/lib/rfqs/cbe-table/feature-flags-provider.tsx @@ -0,0 +1,108 @@ +"use client" + +import * as React from "react" +import { useQueryState } from "nuqs" + +import { dataTableConfig, type DataTableConfig } from "@/config/data-table" +import { cn } from "@/lib/utils" +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" + +type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"] + +interface FeatureFlagsContextProps { + featureFlags: FeatureFlagValue[] + setFeatureFlags: (value: FeatureFlagValue[]) => void +} + +const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({ + featureFlags: [], + setFeatureFlags: () => {}, +}) + +export function useFeatureFlags() { + const context = React.useContext(FeatureFlagsContext) + if (!context) { + throw new Error( + "useFeatureFlags must be used within a FeatureFlagsProvider" + ) + } + return context +} + +interface FeatureFlagsProviderProps { + children: React.ReactNode +} + +export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) { + const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>( + "flags", + { + defaultValue: [], + parse: (value) => value.split(",") as FeatureFlagValue[], + serialize: (value) => value.join(","), + eq: (a, b) => + a.length === b.length && a.every((value, index) => value === b[index]), + clearOnDefault: true, + shallow: false, + } + ) + + return ( + <FeatureFlagsContext.Provider + value={{ + featureFlags, + setFeatureFlags: (value) => void setFeatureFlags(value), + }} + > + <div className="w-full overflow-x-auto"> + <ToggleGroup + type="multiple" + variant="outline" + size="sm" + value={featureFlags} + onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)} + className="w-fit gap-0" + > + {dataTableConfig.featureFlags.map((flag, index) => ( + <Tooltip key={flag.value}> + <ToggleGroupItem + value={flag.value} + className={cn( + "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90", + { + "rounded-l-sm border-r-0": index === 0, + "rounded-r-sm": + index === dataTableConfig.featureFlags.length - 1, + } + )} + asChild + > + <TooltipTrigger> + <flag.icon className="size-3.5 shrink-0" aria-hidden="true" /> + {flag.label} + </TooltipTrigger> + </ToggleGroupItem> + <TooltipContent + align="start" + side="bottom" + sideOffset={6} + className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground" + > + <div>{flag.tooltipTitle}</div> + <div className="text-xs text-muted-foreground"> + {flag.tooltipDescription} + </div> + </TooltipContent> + </Tooltip> + ))} + </ToggleGroup> + </div> + {children} + </FeatureFlagsContext.Provider> + ) +} diff --git a/lib/rfqs/repository.ts b/lib/rfqs/repository.ts new file mode 100644 index 00000000..ad44cf07 --- /dev/null +++ b/lib/rfqs/repository.ts @@ -0,0 +1,232 @@ +// src/lib/tasks/repository.ts +import db from "@/db/db"; +import { items } from "@/db/schema/items"; +import { rfqItems, rfqs, RfqWithItems, rfqsView, type Rfq,VendorResponse, vendorResponses } from "@/db/schema/rfq"; +import { users } from "@/db/schema/users"; +import { + eq, + inArray, + not, + asc, + desc, + and, + ilike, + gte, + lte, + count, + gt, sql +} from "drizzle-orm"; +import { PgTransaction } from "drizzle-orm/pg-core"; +import { RfqType } from "./validations"; +export type NewRfq = typeof rfqs.$inferInsert +export type NewRfqItem = typeof rfqItems.$inferInsert + +/** + * 단건/복수 조회 시 공통으로 사용 가능한 SELECT 함수 예시 + * - 트랜잭션(tx)을 받아서 사용하도록 구현 + */ +export async function selectRfqs( + tx: PgTransaction<any, any, any>, + params: { + where?: any; + orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[]; + offset?: number; + limit?: number; + } +) { + const { where, orderBy, offset = 0, limit = 10 } = params; + + return tx + .select({ + rfqId: rfqsView.id, + id: rfqsView.id, + rfqCode: rfqsView.rfqCode, + description: rfqsView.description, + projectCode: rfqsView.projectCode, + projectName: rfqsView.projectName, + dueDate: rfqsView.dueDate, + status: rfqsView.status, + // createdBy → user 이메일 + createdBy: rfqsView.createdBy, // still the numeric user ID + createdByEmail: rfqsView.userEmail, // string + + createdAt: rfqsView.createdAt, + updatedAt: rfqsView.updatedAt, + // ==================== + // 1) itemCount via subselect + // ==================== + itemCount:rfqsView.itemCount, + attachCount: rfqsView.attachmentCount, + + // user info + // userId: users.id, + userEmail: rfqsView.userEmail, + userName: rfqsView.userName, + }) + .from(rfqsView) + .where(where ?? undefined) + .orderBy(...(orderBy ?? [])) + .offset(offset) + .limit(limit); +} +/** 총 개수 count */ +export async function countRfqs( + tx: PgTransaction<any, any, any>, + where?: any +) { + const res = await tx.select({ count: count() }).from(rfqsView).where(where); + return res[0]?.count ?? 0; +} + +/** 단건 Insert 예시 */ +export async function insertRfq( + tx: PgTransaction<any, any, any>, + data: NewRfq // DB와 동일한 insert 가능한 타입 +) { + // returning() 사용 시 배열로 돌아오므로 [0]만 리턴 + return tx + .insert(rfqs) + .values(data) + .returning({ id: rfqs.id, createdAt: rfqs.createdAt }); +} + +/** 복수 Insert 예시 */ +export async function insertRfqs( + tx: PgTransaction<any, any, any>, + data: Rfq[] +) { + return tx.insert(rfqs).values(data).onConflictDoNothing(); +} + +/** 단건 삭제 */ +export async function deleteRfqById( + tx: PgTransaction<any, any, any>, + rfqId: number +) { + return tx.delete(rfqs).where(eq(rfqs.id, rfqId)); +} + +/** 복수 삭제 */ +export async function deleteRfqsByIds( + tx: PgTransaction<any, any, any>, + ids: number[] +) { + return tx.delete(rfqs).where(inArray(rfqs.id, ids)); +} + +/** 전체 삭제 */ +export async function deleteAllRfqs( + tx: PgTransaction<any, any, any>, +) { + return tx.delete(rfqs); +} + +/** 단건 업데이트 */ +export async function updateRfq( + tx: PgTransaction<any, any, any>, + rfqId: number, + data: Partial<Rfq> +) { + return tx + .update(rfqs) + .set(data) + .where(eq(rfqs.id, rfqId)) + .returning({ status: rfqs.status }); +} + +// /** 복수 업데이트 */ +export async function updateRfqs( + tx: PgTransaction<any, any, any>, + ids: number[], + data: Partial<Rfq> +) { + return tx + .update(rfqs) + .set(data) + .where(inArray(rfqs.id, ids)) + .returning({ status: rfqs.status, dueDate: rfqs.dueDate }); +} + + +// 모든 task 조회 +export const getAllRfqs = async (): Promise<Rfq[]> => { + const datas = await db.select().from(rfqs).execute(); + return datas +}; + + +export async function groupByStatus( + tx: PgTransaction<any, any, any>, + rfqType: RfqType = RfqType.PURCHASE +) { + return tx + .select({ + status: rfqs.status, + count: count(), + }) + .from(rfqs) + .where(eq(rfqs.rfqType, rfqType)) // rfqType으로 필터링 추가 + .groupBy(rfqs.status) + .having(gt(count(), 0)); +} + +export async function insertRfqItem( + tx: PgTransaction<any, any, any>, + data: NewRfqItem +) { + return tx.insert(rfqItems).values(data).returning(); +} + +export const getRfqById = async (id: number): Promise<RfqWithItems | null> => { + // 1) RFQ 단건 조회 + const rfqsRes = await db + .select() + .from(rfqs) + .where(eq(rfqs.id, id)) + .limit(1); + + if (rfqsRes.length === 0) return null; + const rfqRow = rfqsRes[0]; + + // 2) 해당 RFQ 아이템 목록 조회 + const itemsRes = await db + .select() + .from(rfqItems) + .where(eq(rfqItems.rfqId, id)); + + // itemsRes: RfqItem[] + + // 3) RfqWithItems 형태로 반환 + const result: RfqWithItems = { + ...rfqRow, + lines: itemsRes, + }; + + return result; +}; + +/** 단건 업데이트 */ +export async function updateRfqVendor( + tx: PgTransaction<any, any, any>, + rfqVendorId: number, + data: Partial<VendorResponse> +) { + return tx + .update(vendorResponses) + .set(data) + .where(eq(vendorResponses.id, rfqVendorId)) + .returning({ status: vendorResponses.responseStatus }); +} + +/** 복수 업데이트 */ +export async function updateRfqVendors( + tx: PgTransaction<any, any, any>, + ids: number[], + data: Partial<VendorResponse> +) { + return tx + .update(vendorResponses) + .set(data) + .where(inArray(vendorResponses.id, ids)) + .returning({ status: vendorResponses.responseStatus }); +} diff --git a/lib/rfqs/service.ts b/lib/rfqs/service.ts new file mode 100644 index 00000000..b1e02cd0 --- /dev/null +++ b/lib/rfqs/service.ts @@ -0,0 +1,2783 @@ +// src/lib/tasks/service.ts +"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택) + +import { revalidatePath, revalidateTag, unstable_noStore } from "next/cache"; +import db from "@/db/db"; + +import { filterColumns } from "@/lib/filter-columns"; +import { unstable_cache } from "@/lib/unstable-cache"; +import { getErrorMessage } from "@/lib/handle-error"; + +import { GetRfqsSchema, CreateRfqSchema, UpdateRfqSchema, CreateRfqItemSchema, GetMatchedVendorsSchema, GetRfqsForVendorsSchema, UpdateRfqVendorSchema, GetTBESchema, RfqType, GetCBESchema } from "./validations"; +import { asc, desc, ilike, inArray, and, gte, lte, not, or, sql, eq, isNull, ne, isNotNull, count } from "drizzle-orm"; +import path from "path"; +import fs from "fs/promises"; +import { randomUUID } from "crypto"; +import { writeFile, mkdir } from 'fs/promises' +import { join } from 'path' + +import { vendorResponses, vendorResponsesView, Rfq, rfqs, rfqAttachments, rfqItems, RfqWithItems, rfqComments, rfqEvaluations, vendorRfqView, vendorTbeView, rfqsView, vendorResponseAttachments, vendorTechnicalResponses, vendorCbeView, cbeEvaluations, vendorCommercialResponses } from "@/db/schema/rfq"; +import { countRfqs, deleteRfqById, deleteRfqsByIds, getRfqById, groupByStatus, insertRfq, insertRfqItem, selectRfqs, updateRfq, updateRfqs, updateRfqVendor } from "./repository"; +import logger from '@/lib/logger'; +import { vendorPossibleItems, vendors } from "@/db/schema/vendors"; +import { sendEmail } from "../mail/sendEmail"; +import { projects } from "@/db/schema/projects"; +import { items } from "@/db/schema/items"; +import * as z from "zod" + + +interface InviteVendorsInput { + rfqId: number + vendorIds: number[] + rfqType: RfqType +} + +/* ----------------------------------------------------- + 1) 조회 관련 +----------------------------------------------------- */ + +/** + * 복잡한 조건으로 Rfq 목록을 조회 (+ pagination) 하고, + * 총 개수에 따라 pageCount를 계산해서 리턴. + * Next.js의 unstable_cache를 사용해 일정 시간 캐시. + */ +export async function getRfqs(input: GetRfqsSchema) { + return unstable_cache( + async () => { + try { + const offset = (input.page - 1) * input.perPage; + // const advancedTable = input.flags.includes("advancedTable"); + const advancedTable = true; + + // advancedTable 모드면 filterColumns()로 where 절 구성 + const advancedWhere = filterColumns({ + table: rfqsView, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + + let globalWhere + if (input.search) { + const s = `%${input.search}%` + globalWhere = or(ilike(rfqsView.rfqCode, s), ilike(rfqsView.projectCode, s) + , ilike(rfqsView.projectName, s), ilike(rfqsView.dueDate, s), ilike(rfqsView.status, s) + ) + // 필요시 여러 칼럼 OR조건 (status, priority, etc) + } + + let rfqTypeWhere; + if (input.rfqType) { + rfqTypeWhere = eq(rfqsView.rfqType, input.rfqType); + } + + let whereConditions = []; + if (advancedWhere) whereConditions.push(advancedWhere); + if (globalWhere) whereConditions.push(globalWhere); + if (rfqTypeWhere) whereConditions.push(rfqTypeWhere); + + // 조건이 있을 때만 and() 사용 + const finalWhere = whereConditions.length > 0 + ? and(...whereConditions) + : undefined; + + const orderBy = + input.sort.length > 0 + ? input.sort.map((item) => + item.desc ? desc(rfqsView[item.id]) : asc(rfqsView[item.id]) + ) + : [asc(rfqsView.createdAt)]; + + // 트랜잭션 내부에서 Repository 호출 + const { data, total } = await db.transaction(async (tx) => { + const data = await selectRfqs(tx, { + where: finalWhere, + orderBy, + offset, + limit: input.perPage, + }); + + const total = await countRfqs(tx, finalWhere); + return { data, total }; + }); + + + const pageCount = Math.ceil(total / input.perPage); + + + return { data, pageCount }; + } catch (err) { + console.error("getRfqs 에러:", err); // 자세한 에러 로깅 + + // 에러 발생 시 디폴트 + return { data: [], pageCount: 0 }; + } + }, + [JSON.stringify(input)], + { + revalidate: 3600, + tags: [`rfqs-${input.rfqType}`], + } + )(); +} + +/** Status별 개수 */ +export async function getRfqStatusCounts(rfqType: RfqType = RfqType.PURCHASE) { + return unstable_cache( + async () => { + try { + const initial: Record<Rfq["status"], number> = { + DRAFT: 0, + PUBLISHED: 0, + EVALUATION: 0, + AWARDED: 0, + }; + + const result = await db.transaction(async (tx) => { + // rfqType을 기준으로 필터링 추가 + const rows = await groupByStatus(tx, rfqType); + return rows.reduce<Record<Rfq["status"], number>>((acc, { status, count }) => { + acc[status] = count; + return acc; + }, initial); + }); + + return result; + } catch (err) { + return {} as Record<Rfq["status"], number>; + } + }, + [`rfq-status-counts-${rfqType}`], // 캐싱 키에 rfqType 추가 + { + revalidate: 3600, + } + )(); +} + + + +/* ----------------------------------------------------- + 2) 생성(Create) +----------------------------------------------------- */ + +/** + * Rfq 생성 후, (가장 오래된 Rfq 1개) 삭제로 + * 전체 Rfq 개수를 고정 + */ +export async function createRfq(input: CreateRfqSchema) { + + console.log(input.createdBy, "input.createdBy") + + unstable_noStore(); // Next.js 서버 액션 캐싱 방지 + try { + await db.transaction(async (tx) => { + // 새 Rfq 생성 + const [newTask] = await insertRfq(tx, { + rfqCode: input.rfqCode, + projectId: input.projectId || null, + description: input.description || null, + dueDate: input.dueDate, + status: input.status, + rfqType: input.rfqType, // rfqType 추가 + createdBy: input.createdBy, + }); + return newTask; + }); + + // 캐시 무효화 + revalidateTag(`rfqs-${input.rfqType}`); + revalidateTag(`rfq-status-counts-${input.rfqType}`); + + return { data: null, error: null }; + } catch (err) { + return { data: null, error: getErrorMessage(err) }; + } +} + +/* ----------------------------------------------------- + 3) 업데이트 +----------------------------------------------------- */ + +/** 단건 업데이트 */ +export async function modifyRfq(input: UpdateRfqSchema & { id: number }) { + unstable_noStore(); + try { + const data = await db.transaction(async (tx) => { + const [res] = await updateRfq(tx, input.id, { + rfqCode: input.rfqCode, + projectId: input.projectId || null, + dueDate: input.dueDate, + status: input.status as "DRAFT" | "PUBLISHED" | "EVALUATION" | "AWARDED", + createdBy: input.createdBy, + }); + return res; + }); + + revalidateTag("rfqs"); + if (data.status === input.status) { + revalidateTag("rfqs-status-counts"); + } + + + return { data: null, error: null }; + } catch (err) { + return { data: null, error: getErrorMessage(err) }; + } +} + +export async function modifyRfqs(input: { + ids: number[]; + status?: Rfq["status"]; + dueDate?: Date +}) { + unstable_noStore(); + try { + const data = await db.transaction(async (tx) => { + const [res] = await updateRfqs(tx, input.ids, { + status: input.status, + dueDate: input.dueDate, + }); + return res; + }); + + revalidateTag("rfqs"); + if (data.status === input.status) { + revalidateTag("rfq-status-counts"); + } + + + return { data: null, error: null }; + } catch (err) { + return { data: null, error: getErrorMessage(err) }; + } +} + + +/* ----------------------------------------------------- + 4) 삭제 +----------------------------------------------------- */ + +/** 단건 삭제 */ +export async function removeRfq(input: { id: number }) { + unstable_noStore(); + try { + await db.transaction(async (tx) => { + // 삭제 + await deleteRfqById(tx, input.id); + // 바로 새 Rfq 생성 + }); + + revalidateTag("rfqs"); + revalidateTag("rfq-status-counts"); + + + return { data: null, error: null }; + } catch (err) { + return { data: null, error: getErrorMessage(err) }; + } +} + +/** 복수 삭제 */ +export async function removeRfqs(input: { ids: number[] }) { + unstable_noStore(); + try { + await db.transaction(async (tx) => { + // 삭제 + await deleteRfqsByIds(tx, input.ids); + }); + + revalidateTag("rfqs"); + revalidateTag("rfq-status-counts"); + + return { data: null, error: null }; + } catch (err) { + return { data: null, error: getErrorMessage(err) }; + } +} + +// 삭제를 위한 입력 스키마 +const deleteRfqItemSchema = z.object({ + id: z.number().int(), + rfqId: z.number().int(), + rfqType: z.nativeEnum(RfqType).default(RfqType.PURCHASE), +}); + +type DeleteRfqItemSchema = z.infer<typeof deleteRfqItemSchema>; + +/** + * RFQ 아이템 삭제 함수 + */ +export async function deleteRfqItem(input: DeleteRfqItemSchema) { + unstable_noStore(); // Next.js 서버 액션 캐싱 방지 + + try { + // 삭제 작업 수행 + await db + .delete(rfqItems) + .where( + and( + eq(rfqItems.id, input.id), + eq(rfqItems.rfqId, input.rfqId) + ) + ); + + console.log(`Deleted RFQ item: ${input.id} for RFQ ${input.rfqId}`); + + // 캐시 무효화 + revalidateTag("rfq-items"); + revalidateTag(`rfqs-${input.rfqType}`); + revalidateTag(`rfq-${input.rfqId}`); + + return { data: null, error: null }; + } catch (err) { + console.error("Error in deleteRfqItem:", err); + return { data: null, error: getErrorMessage(err) }; + } +} + +// createRfqItem 함수 수정 (id 파라미터 추가) +export async function createRfqItem(input: CreateRfqItemSchema & { id?: number }) { + unstable_noStore(); + + try { + // DB 트랜잭션 + await db.transaction(async (tx) => { + // id가 전달되었으면 해당 id로 업데이트, 그렇지 않으면 기존 로직대로 진행 + if (input.id) { + // 기존 아이템 업데이트 + await tx + .update(rfqItems) + .set({ + description: input.description ?? null, + quantity: input.quantity ?? 1, + uom: input.uom ?? "", + updatedAt: new Date(), + }) + .where(eq(rfqItems.id, input.id)); + + console.log(`Updated RFQ item with id: ${input.id}`); + } else { + // 기존 로직: 같은 itemCode로 이미 존재하는지 확인 후 업데이트/생성 + const existingItems = await tx + .select() + .from(rfqItems) + .where( + and( + eq(rfqItems.rfqId, input.rfqId), + eq(rfqItems.itemCode, input.itemCode) + ) + ); + + if (existingItems.length > 0) { + // 이미 존재하는 경우 업데이트 + const existingItem = existingItems[0]; + await tx + .update(rfqItems) + .set({ + description: input.description ?? null, + quantity: input.quantity ?? 1, + uom: input.uom ?? "", + updatedAt: new Date(), + }) + .where(eq(rfqItems.id, existingItem.id)); + + console.log(`Updated existing RFQ item: ${existingItem.id} for RFQ ${input.rfqId}, Item ${input.itemCode}`); + } else { + // 존재하지 않는 경우 새로 생성 + const [newItem] = await insertRfqItem(tx, { + rfqId: input.rfqId, + itemCode: input.itemCode, + description: input.description ?? null, + quantity: input.quantity ?? 1, + uom: input.uom ?? "", + }); + + console.log(`Created new RFQ item for RFQ ${input.rfqId}, Item ${input.itemCode}`); + } + } + }); + + // 캐시 무효화 + revalidateTag("rfq-items"); + revalidateTag(`rfqs-${input.rfqType}`); + revalidateTag(`rfq-${input.rfqId}`); + + return { data: null, error: null }; + } catch (err) { + console.error("Error in createRfqItem:", err); + return { data: null, error: getErrorMessage(err) }; + } +} +/** + * 서버 액션: 파일 첨부/삭제 처리 + * @param rfqId RFQ ID + * @param removedExistingIds 기존 첨부 중 삭제된 record ID 배열 + * @param newFiles 새로 업로드된 파일 (File[]) - Next.js server action에서 + * @param vendorId (optional) 업로더가 vendor인지 구분 + */ +export async function processRfqAttachments(args: { + rfqId: number; + removedExistingIds?: number[]; + newFiles?: File[]; + vendorId?: number | null; + rfqType?: RfqType | null; +}) { + const { rfqId, removedExistingIds = [], newFiles = [], vendorId = null } = args; + + try { + // 1) 삭제된 기존 첨부: DB + 파일시스템에서 제거 + if (removedExistingIds.length > 0) { + // 1-1) DB에서 filePath 조회 + const rows = await db + .select({ + id: rfqAttachments.id, + filePath: rfqAttachments.filePath + }) + .from(rfqAttachments) + .where(inArray(rfqAttachments.id, removedExistingIds)); + + // 1-2) DB 삭제 + await db + .delete(rfqAttachments) + .where(inArray(rfqAttachments.id, removedExistingIds)); + + // 1-3) 파일 삭제 + for (const row of rows) { + // filePath: 예) "/rfq/123/...xyz" + const absolutePath = path.join( + process.cwd(), + "public", + row.filePath.replace(/^\/+/, "") // 슬래시 제거 + ); + try { + await fs.unlink(absolutePath); + } catch (err) { + console.error("File remove error:", err); + } + } + } + + // 2) 새 파일 업로드 + if (newFiles.length > 0) { + const rfqDir = path.join("public", "rfq", String(rfqId)); + // 폴더 없으면 생성 + await fs.mkdir(rfqDir, { recursive: true }); + + for (const file of newFiles) { + // 2-1) File -> Buffer + const ab = await file.arrayBuffer(); + const buffer = Buffer.from(ab); + + // 2-2) 고유 파일명 + const uniqueName = `${randomUUID()}-${file.name}`; + // 예) "rfq/123/xxx" + const relativePath = path.join("rfq", String(rfqId), uniqueName); + const absolutePath = path.join("public", relativePath); + + // 2-3) 파일 저장 + await fs.writeFile(absolutePath, buffer); + + // 2-4) DB Insert + await db.insert(rfqAttachments).values({ + rfqId, + vendorId, + fileName: file.name, + filePath: "/" + relativePath.replace(/\\/g, "/"), + // (Windows 경로 대비) + }); + } + } + + const [countRow] = await db + .select({ cnt: sql<number>`count(*)`.as("cnt") }) + .from(rfqAttachments) + .where(eq(rfqAttachments.rfqId, rfqId)); + + const newCount = countRow?.cnt ?? 0; + + // 3) revalidateTag 등 캐시 무효화 + revalidateTag("rfq-attachments"); + revalidateTag(`rfqs-${args.rfqType}`) + + return { ok: true, updatedItemCount: newCount }; + } catch (error) { + console.error("processRfqAttachments error:", error); + return { ok: false, error: String(error) }; + } +} + + + +export async function fetchRfqAttachments(rfqId: number) { + // DB select + const rows = await db + .select() + .from(rfqAttachments) + .where(eq(rfqAttachments.rfqId, rfqId)) + + // rows: { id, fileName, filePath, createdAt, vendorId, ... } + // 필요 없는 필드는 omit하거나 transform 가능 + return rows.map((row) => ({ + id: row.id, + fileName: row.fileName, + filePath: row.filePath, + createdAt: row.createdAt, // or string + vendorId: row.vendorId, + size: undefined, // size를 DB에 저장하지 않았다면 + })) +} + +export async function fetchRfqItems(rfqId: number) { + // DB select + const rows = await db + .select() + .from(rfqItems) + .where(eq(rfqItems.rfqId, rfqId)) + + // rows: { id, fileName, filePath, createdAt, vendorId, ... } + // 필요 없는 필드는 omit하거나 transform 가능 + return rows.map((row) => ({ + // id: row.id, + itemCode: row.itemCode, + description: row.description, + quantity: row.quantity, + uom: row.uom, + })) +} + +export const findRfqById = async (id: number): Promise<RfqWithItems | null> => { + try { + logger.info({ id }, 'Fetching user by ID'); + const rfq = await getRfqById(id); + if (!rfq) { + logger.warn({ id }, 'User not found'); + } else { + logger.debug({ rfq }, 'User fetched successfully'); + } + return rfq; + } catch (error) { + logger.error({ error }, 'Error fetching user by ID'); + throw new Error('Failed to fetch user'); + } +}; + +export async function getMatchedVendors(input: GetMatchedVendorsSchema, rfqId: number) { + return unstable_cache( + async () => { + // ───────────────────────────────────────────────────── + // 1) rfq_items에서 distinct itemCode + // ───────────────────────────────────────────────────── + const itemRows = await db + .select({ code: rfqItems.itemCode }) + .from(rfqItems) + .where(eq(rfqItems.rfqId, rfqId)) + .groupBy(rfqItems.itemCode) + + const itemCodes = itemRows.map((r) => r.code) + const itemCount = itemCodes.length + if (itemCount === 0) { + return { data: [], pageCount: 0 } + } + + // ───────────────────────────────────────────────────── + // 2) vendorPossibleItems에서 모든 itemCodes를 보유한 vendor + // ───────────────────────────────────────────────────── + const inList = itemCodes.map((c) => `'${c}'`).join(",") + const sqlVendorIds = await db.execute( + sql` + SELECT vpi.vendor_id AS "vendorId" + FROM ${vendorPossibleItems} vpi + WHERE vpi.item_code IN (${sql.raw(inList)}) + GROUP BY vpi.vendor_id + HAVING COUNT(DISTINCT vpi.item_code) = ${itemCount} + ` + ) + const vendorIdList = sqlVendorIds.rows.map((row: any) => +row.vendorId) + if (vendorIdList.length === 0) { + return { data: [], pageCount: 0 } + } + + console.log(vendorIdList,"vendorIdList") + + // ───────────────────────────────────────────────────── + // 3) 필터/검색/정렬 + // ───────────────────────────────────────────────────── + const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10) + const limit = input.perPage ?? 10 + + // (가) 커스텀 필터 + // 여기서는 "뷰(vendorRfqView)"의 컬럼들에 대해 필터합니다. + const advancedWhere = filterColumns({ + // 테이블이 아니라 "뷰"를 넘길 수도 있고, + // 혹은 columns 객체(연결된 모든 컬럼)로 넘겨도 됩니다. + table: vendorRfqView, + filters: input.filters ?? [], + joinOperator: input.joinOperator ?? "and", + }) + + // (나) 글로벌 검색 + let globalWhere + if (input.search) { + const s = `%${input.search}%` + globalWhere = or( + sql`${vendorRfqView.vendorName} ILIKE ${s}`, + sql`${vendorRfqView.vendorCode} ILIKE ${s}`, + sql`${vendorRfqView.email} ILIKE ${s}` + ) + } + + // (다) 최종 where + // vendorId가 vendorIdList 내에 있어야 하고, + // 특정 rfqId(뷰에 담긴 값)도 일치해야 함. + const finalWhere = and( + inArray(vendorRfqView.vendorId, vendorIdList), + eq(vendorRfqView.rfqId, rfqId), + advancedWhere, + globalWhere + ) + + // (라) 정렬 + const orderBy = input.sort?.length + ? input.sort.map((s) => { + // "column id" -> vendorRfqView.* 중 하나 + const col = (vendorRfqView as any)[s.id] + return s.desc ? desc(col) : asc(col) + }) + : [asc(vendorRfqView.vendorId)] + + // ───────────────────────────────────────────────────── + // 4) View에서 데이터 SELECT + // ───────────────────────────────────────────────────── + const [rows, total] = await db.transaction(async (tx) => { + const data = await tx + .select({ + id: vendorRfqView.vendorId, + vendorID: vendorRfqView.vendorId, + vendorName: vendorRfqView.vendorName, + vendorCode: vendorRfqView.vendorCode, + address: vendorRfqView.address, + country: vendorRfqView.country, + email: vendorRfqView.email, + website: vendorRfqView.website, + vendorStatus: vendorRfqView.vendorStatus, + // rfqVendorStatus와 rfqVendorUpdated는 나중에 정확한 데이터로 교체할 예정 + rfqVendorStatus: vendorRfqView.rfqVendorStatus, + rfqVendorUpdated: vendorRfqView.rfqVendorUpdated, + }) + .from(vendorRfqView) + .where(finalWhere) + .orderBy(...orderBy) + .offset(offset) + .limit(limit) + + // 총 개수 + const [{ count }] = await tx + .select({ count: sql<number>`count(*)`.as("count") }) + .from(vendorRfqView) + .where(finalWhere) + + return [data, Number(count)] + }) + + + console.log(rows) + console.log(total) + // ───────────────────────────────────────────────────── + // 4-1) 정확한 rfqVendorStatus와 rfqVendorUpdated 조회 + // ───────────────────────────────────────────────────── + const distinctVendorIds = [...new Set(rows.map((r) => r.id))] + + // vendorResponses 테이블에서 정확한 상태와 업데이트 시간 조회 + const vendorStatuses = await db + .select({ + vendorId: vendorResponses.vendorId, + status: vendorResponses.responseStatus, + updatedAt: vendorResponses.updatedAt + }) + .from(vendorResponses) + .where( + and( + inArray(vendorResponses.vendorId, distinctVendorIds), + eq(vendorResponses.rfqId, rfqId) + ) + ) + + // vendorId별 상태정보 맵 생성 + const statusMap = new Map<number, { status: string, updatedAt: Date }>() + for (const vs of vendorStatuses) { + statusMap.set(vs.vendorId, { + status: vs.status, + updatedAt: vs.updatedAt + }) + } + + // 정확한 상태 정보로 업데이트된 rows 생성 + const updatedRows = rows.map(row => ({ + ...row, + rfqVendorStatus: statusMap.get(row.id)?.status || null, + rfqVendorUpdated: statusMap.get(row.id)?.updatedAt || null + })) + + // ───────────────────────────────────────────────────── + // 5) 코멘트 조회: 기존과 동일 + // ───────────────────────────────────────────────────── + const commAll = await db + .select() + .from(rfqComments) + .where( + and( + inArray(rfqComments.vendorId, distinctVendorIds), + eq(rfqComments.rfqId, rfqId) + ) + ) + + const commByVendorId = new Map<number, any[]>() + for (const c of commAll) { + const vid = c.vendorId! + if (!commByVendorId.has(vid)) { + commByVendorId.set(vid, []) + } + commByVendorId.get(vid)!.push({ + id: c.id, + commentText: c.commentText, + vendorId: c.vendorId, + evaluationId: c.evaluationId, + createdAt: c.createdAt, + commentedBy: c.commentedBy, + }) + } + + // ───────────────────────────────────────────────────── + // 6) rows에 comments 병합 + // ───────────────────────────────────────────────────── + const final = updatedRows.map((row) => ({ + ...row, + comments: commByVendorId.get(row.id) ?? [], + })) + + // ───────────────────────────────────────────────────── + // 7) 반환 + // ───────────────────────────────────────────────────── + const pageCount = Math.ceil(total / limit) + return { data: final, pageCount } + }, + [JSON.stringify({ input, rfqId })], + { revalidate: 3600, tags: ["rfq-vendors"] } + )() +} + +export async function inviteVendors(input: InviteVendorsInput) { + unstable_noStore() // 서버 액션 캐싱 방지 + try { + const { rfqId, vendorIds } = input + if (!rfqId || !Array.isArray(vendorIds) || vendorIds.length === 0) { + throw new Error("Invalid input") + } + + // DB 데이터 준비 및 첨부파일 처리를 위한 트랜잭션 + const rfqData = await db.transaction(async (tx) => { + // 2-A) RFQ 기본 정보 조회 + const [rfqRow] = await tx + .select({ + rfqCode: rfqsView.rfqCode, + description: rfqsView.description, + projectCode: rfqsView.projectCode, + projectName: rfqsView.projectName, + dueDate: rfqsView.dueDate, + createdBy: rfqsView.createdBy, + }) + .from(rfqsView) + .where(eq(rfqsView.id, rfqId)) + + if (!rfqRow) { + throw new Error(`RFQ #${rfqId} not found`) + } + + // 2-B) 아이템 목록 조회 + const items = await tx + .select({ + itemCode: rfqItems.itemCode, + description: rfqItems.description, + quantity: rfqItems.quantity, + uom: rfqItems.uom, + }) + .from(rfqItems) + .where(eq(rfqItems.rfqId, rfqId)) + + // 2-C) 첨부파일 목록 조회 + const attachRows = await tx + .select({ + id: rfqAttachments.id, + fileName: rfqAttachments.fileName, + filePath: rfqAttachments.filePath, + }) + .from(rfqAttachments) + .where( + and( + eq(rfqAttachments.rfqId, rfqId), + isNull(rfqAttachments.vendorId), + isNull(rfqAttachments.evaluationId) + ) + ) + + const vendorRows = await tx + .select({ id: vendors.id, email: vendors.email }) + .from(vendors) + .where(inArray(vendors.id, vendorIds)) + + // NodeMailer attachments 형식 맞추기 + const attachments = [] + for (const att of attachRows) { + const absolutePath = path.join(process.cwd(), "public", att.filePath.replace(/^\/+/, "")) + attachments.push({ + path: absolutePath, + filename: att.fileName, + }) + } + + return { rfqRow, items, vendorRows, attachments } + }) + + const { rfqRow, items, vendorRows, attachments } = rfqData + const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://3.36.56.124:3000' + const loginUrl = `${baseUrl}/en/partners/rfq` + + // 이메일 전송 오류를 기록할 배열 + const emailErrors = [] + + // 각 벤더에 대해 처리 + for (const v of vendorRows) { + if (!v.email) { + continue // 이메일 없는 벤더 무시 + } + + try { + // DB 업데이트: 각 벤더 상태 별도 트랜잭션 + await db.transaction(async (tx) => { + // rfq_vendors upsert + const existing = await tx + .select() + .from(vendorResponses) + .where(and(eq(vendorResponses.rfqId, rfqId), eq(vendorResponses.vendorId, v.id))) + + if (existing.length > 0) { + await tx + .update(vendorResponses) + .set({ + responseStatus: "INVITED", + updatedAt: new Date(), + }) + .where(eq(vendorResponses.id, existing[0].id)) + } else { + await tx.insert(vendorResponses).values({ + rfqId, + vendorId: v.id, + responseStatus: "INVITED", + }) + } + }) + + // 이메일 발송 (트랜잭션 외부) + await sendEmail({ + to: v.email, + subject: `[RFQ ${rfqRow.rfqCode}] You are invited from Samgsung Heavy Industries!`, + template: "rfq-invite", + context: { + language: "en", + rfqId, + vendorId: v.id, + rfqCode: rfqRow.rfqCode, + projectCode: rfqRow.projectCode, + projectName: rfqRow.projectName, + dueDate: rfqRow.dueDate, + description: rfqRow.description, + items: items.map((it) => ({ + itemCode: it.itemCode, + description: it.description, + quantity: it.quantity, + uom: it.uom, + })), + loginUrl + }, + attachments, + }) + } catch (err) { + // 개별 벤더 처리 실패 로깅 + console.error(`Failed to process vendor ${v.id}: ${getErrorMessage(err)}`) + emailErrors.push({ vendorId: v.id, error: getErrorMessage(err) }) + // 계속 진행 (다른 벤더 처리) + } + } + + // 최종적으로 RFQ 상태 업데이트 (별도 트랜잭션) + try { + await db.transaction(async (tx) => { + await tx + .update(rfqs) + .set({ + status: "PUBLISHED", + updatedAt: new Date(), + }) + .where(eq(rfqs.id, rfqId)) + + console.log(`Updated RFQ #${rfqId} status to PUBLISHED`) + }) + + // 캐시 무효화 + revalidateTag("rfq-vendors") + revalidateTag("cbe-vendors") + revalidateTag("rfqs") + revalidateTag(`rfqs-${input.rfqType}`) + revalidateTag(`rfq-${rfqId}`) + + // 이메일 오류가 있었는지 확인 + if (emailErrors.length > 0) { + return { + error: `일부 벤더에게 이메일 발송 실패 (${emailErrors.length}/${vendorRows.length}), RFQ 상태는 업데이트됨`, + emailErrors + } + } + + return { error: null } + } catch (err) { + return { error: `RFQ 상태 업데이트 실패: ${getErrorMessage(err)}` } + } + } catch (err) { + return { error: getErrorMessage(err) } + } +} + + +/** + * TBE용 평가 데이터 목록 조회 + */ +export async function getTBE(input: GetTBESchema, rfqId: number) { + return unstable_cache( + async () => { + // 1) 페이징 + const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10) + const limit = input.perPage ?? 10 + + // 2) 고급 필터 + const advancedWhere = filterColumns({ + table: vendorTbeView, + filters: input.filters ?? [], + joinOperator: input.joinOperator ?? "and", + }) + + // 3) 글로벌 검색 + let globalWhere + if (input.search) { + const s = `%${input.search}%` + globalWhere = or( + sql`${vendorTbeView.vendorName} ILIKE ${s}`, + sql`${vendorTbeView.vendorCode} ILIKE ${s}`, + sql`${vendorTbeView.email} ILIKE ${s}` + ) + } + + // 4) REJECTED 아니거나 NULL + const notRejected = or( + ne(vendorTbeView.rfqVendorStatus, "REJECTED"), + isNull(vendorTbeView.rfqVendorStatus) + ) + + // 5) finalWhere + const finalWhere = and( + eq(vendorTbeView.rfqId, rfqId), + notRejected, + advancedWhere, + globalWhere + ) + + // 6) 정렬 + const orderBy = input.sort?.length + ? input.sort.map((s) => { + const col = (vendorTbeView as any)[s.id] + return s.desc ? desc(col) : asc(col) + }) + : [asc(vendorTbeView.vendorId)] + + // 7) 메인 SELECT + const [rows, total] = await db.transaction(async (tx) => { + const data = await tx + .select({ + // 원하는 컬럼들 + id: vendorTbeView.vendorId, + tbeId: vendorTbeView.tbeId, + vendorId: vendorTbeView.vendorId, + vendorName: vendorTbeView.vendorName, + vendorCode: vendorTbeView.vendorCode, + address: vendorTbeView.address, + country: vendorTbeView.country, + email: vendorTbeView.email, + website: vendorTbeView.website, + vendorStatus: vendorTbeView.vendorStatus, + + rfqId: vendorTbeView.rfqId, + rfqCode: vendorTbeView.rfqCode, + projectCode: vendorTbeView.projectCode, + projectName: vendorTbeView.projectName, + description: vendorTbeView.description, + dueDate: vendorTbeView.dueDate, + + rfqVendorStatus: vendorTbeView.rfqVendorStatus, + rfqVendorUpdated: vendorTbeView.rfqVendorUpdated, + + tbeResult: vendorTbeView.tbeResult, + tbeNote: vendorTbeView.tbeNote, + tbeUpdated: vendorTbeView.tbeUpdated, + }) + .from(vendorTbeView) + .where(finalWhere) + .orderBy(...orderBy) + .offset(offset) + .limit(limit) + + const [{ count }] = await tx + .select({ count: sql<number>`count(*)`.as("count") }) + .from(vendorTbeView) + .where(finalWhere) + + return [data, Number(count)] + }) + + if (!rows.length) { + return { data: [], pageCount: 0 } + } + + // 8) Comments 조회 + const distinctVendorIds = [...new Set(rows.map((r) => r.vendorId))] + + const commAll = await db + .select({ + id: rfqComments.id, + commentText: rfqComments.commentText, + vendorId: rfqComments.vendorId, + evaluationId: rfqComments.evaluationId, + createdAt: rfqComments.createdAt, + commentedBy: rfqComments.commentedBy, + evalType: rfqEvaluations.evalType, + }) + .from(rfqComments) + .innerJoin( + rfqEvaluations, + and( + eq(rfqEvaluations.id, rfqComments.evaluationId), + eq(rfqEvaluations.evalType, "TBE") + ) + ) + .where( + and( + isNotNull(rfqComments.evaluationId), + eq(rfqComments.rfqId, rfqId), + inArray(rfqComments.vendorId, distinctVendorIds) + ) + ) + + // 8-A) vendorId -> comments grouping + const commByVendorId = new Map<number, any[]>() + for (const c of commAll) { + const vid = c.vendorId! + if (!commByVendorId.has(vid)) { + commByVendorId.set(vid, []) + } + commByVendorId.get(vid)!.push({ + id: c.id, + commentText: c.commentText, + vendorId: c.vendorId, + evaluationId: c.evaluationId, + createdAt: c.createdAt, + commentedBy: c.commentedBy, + }) + } + + // 9) TBE 파일 조회 - vendorResponseAttachments로 대체 + // Step 1: Get vendorResponses for the rfqId and vendorIds + const responsesAll = await db + .select({ + id: vendorResponses.id, + vendorId: vendorResponses.vendorId + }) + .from(vendorResponses) + .where( + and( + eq(vendorResponses.rfqId, rfqId), + inArray(vendorResponses.vendorId, distinctVendorIds) + ) + ); + + // Group responses by vendorId for later lookup + const responsesByVendorId = new Map<number, number[]>(); + for (const resp of responsesAll) { + if (!responsesByVendorId.has(resp.vendorId)) { + responsesByVendorId.set(resp.vendorId, []); + } + responsesByVendorId.get(resp.vendorId)!.push(resp.id); + } + + // Step 2: Get all responseIds + const allResponseIds = responsesAll.map(r => r.id); + + // Step 3: Get technicalResponses for these responseIds + const technicalResponsesAll = await db + .select({ + id: vendorTechnicalResponses.id, + responseId: vendorTechnicalResponses.responseId + }) + .from(vendorTechnicalResponses) + .where(inArray(vendorTechnicalResponses.responseId, allResponseIds)); + + // Create mapping from responseId to technicalResponseIds + const technicalResponseIdsByResponseId = new Map<number, number[]>(); + for (const tr of technicalResponsesAll) { + if (!technicalResponseIdsByResponseId.has(tr.responseId)) { + technicalResponseIdsByResponseId.set(tr.responseId, []); + } + technicalResponseIdsByResponseId.get(tr.responseId)!.push(tr.id); + } + + // Step 4: Get all technicalResponseIds + const allTechnicalResponseIds = technicalResponsesAll.map(tr => tr.id); + + // Step 5: Get attachments for these technicalResponseIds + const filesAll = await db + .select({ + id: vendorResponseAttachments.id, + fileName: vendorResponseAttachments.fileName, + filePath: vendorResponseAttachments.filePath, + technicalResponseId: vendorResponseAttachments.technicalResponseId, + fileType: vendorResponseAttachments.fileType, + attachmentType: vendorResponseAttachments.attachmentType, + description: vendorResponseAttachments.description, + uploadedAt: vendorResponseAttachments.uploadedAt, + uploadedBy: vendorResponseAttachments.uploadedBy + }) + .from(vendorResponseAttachments) + .where( + and( + inArray(vendorResponseAttachments.technicalResponseId, allTechnicalResponseIds), + isNotNull(vendorResponseAttachments.technicalResponseId) + ) + ); + + // Step 6: Create mapping from technicalResponseId to attachments + const filesByTechnicalResponseId = new Map<number, any[]>(); + for (const file of filesAll) { + // Skip if technicalResponseId is null (should never happen due to our filter above) + if (file.technicalResponseId === null) continue; + + if (!filesByTechnicalResponseId.has(file.technicalResponseId)) { + filesByTechnicalResponseId.set(file.technicalResponseId, []); + } + filesByTechnicalResponseId.get(file.technicalResponseId)!.push({ + id: file.id, + fileName: file.fileName, + filePath: file.filePath, + fileType: file.fileType, + attachmentType: file.attachmentType, + description: file.description, + uploadedAt: file.uploadedAt, + uploadedBy: file.uploadedBy + }); + } + + // Step 7: Create the final filesByVendorId map + const filesByVendorId = new Map<number, any[]>(); + for (const [vendorId, responseIds] of responsesByVendorId.entries()) { + filesByVendorId.set(vendorId, []); + + for (const responseId of responseIds) { + const technicalResponseIds = technicalResponseIdsByResponseId.get(responseId) || []; + + for (const technicalResponseId of technicalResponseIds) { + const files = filesByTechnicalResponseId.get(technicalResponseId) || []; + filesByVendorId.get(vendorId)!.push(...files); + } + } + } + + // 10) 최종 합치기 + const final = rows.map((row) => ({ + ...row, + dueDate: row.dueDate ? new Date(row.dueDate) : null, + comments: commByVendorId.get(row.vendorId) ?? [], + files: filesByVendorId.get(row.vendorId) ?? [], + })) + + const pageCount = Math.ceil(total / limit) + return { data: final, pageCount } + }, + [JSON.stringify({ input, rfqId })], + { + revalidate: 3600, + tags: ["tbe-vendors"], + } + )() +} + +export async function getTBEforVendor(input: GetTBESchema, vendorId: number) { + return unstable_cache( + async () => { + // 1) 페이징 + const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10) + const limit = input.perPage ?? 10 + + // 2) 고급 필터 + const advancedWhere = filterColumns({ + table: vendorTbeView, + filters: input.filters ?? [], + joinOperator: input.joinOperator ?? "and", + }) + + // 3) 글로벌 검색 + let globalWhere + if (input.search) { + const s = `%${input.search}%` + globalWhere = or( + sql`${vendorTbeView.vendorName} ILIKE ${s}`, + sql`${vendorTbeView.vendorCode} ILIKE ${s}`, + sql`${vendorTbeView.email} ILIKE ${s}` + ) + } + + // 4) REJECTED 아니거나 NULL + const notRejected = or( + ne(vendorTbeView.rfqVendorStatus, "REJECTED"), + isNull(vendorTbeView.rfqVendorStatus) + ) + + // 5) finalWhere + const finalWhere = and( + isNotNull(vendorTbeView.tbeId), + eq(vendorTbeView.vendorId, vendorId), + + notRejected, + advancedWhere, + globalWhere + ) + + // 6) 정렬 + const orderBy = input.sort?.length + ? input.sort.map((s) => { + const col = (vendorTbeView as any)[s.id] + return s.desc ? desc(col) : asc(col) + }) + : [asc(vendorTbeView.vendorId)] + + // 7) 메인 SELECT + const [rows, total] = await db.transaction(async (tx) => { + const data = await tx + .select({ + // 원하는 컬럼들 + id: vendorTbeView.vendorId, + tbeId: vendorTbeView.tbeId, + vendorId: vendorTbeView.vendorId, + vendorName: vendorTbeView.vendorName, + vendorCode: vendorTbeView.vendorCode, + address: vendorTbeView.address, + country: vendorTbeView.country, + email: vendorTbeView.email, + website: vendorTbeView.website, + vendorStatus: vendorTbeView.vendorStatus, + + rfqId: vendorTbeView.rfqId, + rfqCode: vendorTbeView.rfqCode, + projectCode: vendorTbeView.projectCode, + projectName: vendorTbeView.projectName, + description: vendorTbeView.description, + dueDate: vendorTbeView.dueDate, + + vendorResponseId: vendorTbeView.vendorResponseId, + rfqVendorStatus: vendorTbeView.rfqVendorStatus, + rfqVendorUpdated: vendorTbeView.rfqVendorUpdated, + + tbeResult: vendorTbeView.tbeResult, + tbeNote: vendorTbeView.tbeNote, + tbeUpdated: vendorTbeView.tbeUpdated, + }) + .from(vendorTbeView) + .where(finalWhere) + .orderBy(...orderBy) + .offset(offset) + .limit(limit) + + const [{ count }] = await tx + .select({ count: sql<number>`count(*)`.as("count") }) + .from(vendorTbeView) + .where(finalWhere) + + return [data, Number(count)] + }) + + if (!rows.length) { + return { data: [], pageCount: 0 } + } + + // 8) Comments 조회 + // - evaluationId != null && evalType = "TBE" + // - => leftJoin(rfqEvaluations) or innerJoin + const distinctVendorIds = [...new Set(rows.map((r) => r.vendorId))] + const distinctTbeIds = [...new Set(rows.map((r) => r.tbeId).filter(Boolean))] + + // (A) 조인 방식 + const commAll = await db + .select({ + id: rfqComments.id, + commentText: rfqComments.commentText, + vendorId: rfqComments.vendorId, + evaluationId: rfqComments.evaluationId, + createdAt: rfqComments.createdAt, + commentedBy: rfqComments.commentedBy, + evalType: rfqEvaluations.evalType, // (optional) + }) + .from(rfqComments) + // evalType = 'TBE' + .innerJoin( + rfqEvaluations, + and( + eq(rfqEvaluations.id, rfqComments.evaluationId), + eq(rfqEvaluations.evalType, "TBE") // ★ TBE만 + ) + ) + .where( + and( + isNotNull(rfqComments.evaluationId), + inArray(rfqComments.vendorId, distinctVendorIds) + ) + ) + + // 8-A) vendorId -> comments grouping + const commByVendorId = new Map<number, any[]>() + for (const c of commAll) { + const vid = c.vendorId! + if (!commByVendorId.has(vid)) { + commByVendorId.set(vid, []) + } + commByVendorId.get(vid)!.push({ + id: c.id, + commentText: c.commentText, + vendorId: c.vendorId, + evaluationId: c.evaluationId, + createdAt: c.createdAt, + commentedBy: c.commentedBy, + }) + } + + // 9) TBE 템플릿 파일 수 조회 + const templateFiles = await db + .select({ + tbeId: rfqAttachments.evaluationId, + fileCount: sql<number>`count(*)`.as("file_count"), + }) + .from(rfqAttachments) + .where( + and( + inArray(rfqAttachments.evaluationId, distinctTbeIds), + isNull(rfqAttachments.vendorId), + isNull(rfqAttachments.commentId) + ) + ) + .groupBy(rfqAttachments.evaluationId) + + // tbeId -> fileCount 매핑 - null 체크 추가 + const templateFileCountMap = new Map<number, number>() + for (const tf of templateFiles) { + if (tf.tbeId !== null) { + templateFileCountMap.set(tf.tbeId, Number(tf.fileCount)) + } + } + + // 10) TBE 응답 파일 확인 (각 tbeId + vendorId 조합에 대해) + const tbeResponseFiles = await db + .select({ + tbeId: rfqAttachments.evaluationId, + vendorId: rfqAttachments.vendorId, + responseFileCount: sql<number>`count(*)`.as("response_file_count"), + }) + .from(rfqAttachments) + .where( + and( + inArray(rfqAttachments.evaluationId, distinctTbeIds), + inArray(rfqAttachments.vendorId, distinctVendorIds), + isNull(rfqAttachments.commentId) + ) + ) + .groupBy(rfqAttachments.evaluationId, rfqAttachments.vendorId) + + // tbeId_vendorId -> hasResponse 매핑 - null 체크 추가 + const tbeResponseMap = new Map<string, number>() + for (const rf of tbeResponseFiles) { + if (rf.tbeId !== null && rf.vendorId !== null) { + const key = `${rf.tbeId}_${rf.vendorId}` + tbeResponseMap.set(key, Number(rf.responseFileCount)) + } + } + + // 11) 최종 합치기 + const final = rows.map((row) => { + const tbeId = row.tbeId + const vendorId = row.vendorId + + // 템플릿 파일 수 + const templateFileCount = tbeId !== null ? templateFileCountMap.get(tbeId) || 0 : 0 + + // 응답 파일 여부 + const responseKey = tbeId !== null ? `${tbeId}_${vendorId}` : "" + const responseFileCount = responseKey ? tbeResponseMap.get(responseKey) || 0 : 0 + + return { + ...row, + dueDate: row.dueDate ? new Date(row.dueDate) : null, + comments: commByVendorId.get(row.vendorId) ?? [], + templateFileCount, // 추가: 템플릿 파일 수 + hasResponse: responseFileCount > 0, // 추가: 응답 파일 제출 여부 + } + }) + + const pageCount = Math.ceil(total / limit) + return { data: final, pageCount } + }, + [JSON.stringify(input), String(vendorId)], // 캐싱 키에 packagesId 추가 + { + revalidate: 3600, + tags: [`tbe-vendors-${vendorId}`], + } + )() +} + +export async function inviteTbeVendorsAction(formData: FormData) { + // 캐싱 방지 + unstable_noStore() + + try { + // 1) FormData에서 기본 필드 추출 + const rfqId = Number(formData.get("rfqId")) + const vendorIdsRaw = formData.getAll("vendorIds[]") + const vendorIds = vendorIdsRaw.map((id) => Number(id)) + + + // 2) FormData에서 파일들 추출 (multiple) + const tbeFiles = formData.getAll("tbeFiles") as File[] + if (!rfqId || !vendorIds.length || !tbeFiles.length) { + throw new Error("Invalid input or no files attached.") + } + + // /public/rfq/[rfqId] 경로 + const uploadDir = path.join(process.cwd(), "public", "rfq", String(rfqId)) + + + // DB 트랜잭션 + await db.transaction(async (tx) => { + // (A) RFQ 기본 정보 조회 + const [rfqRow] = await tx + .select({ + rfqCode: vendorResponsesView.rfqCode, + description: vendorResponsesView.rfqDescription, + projectCode: vendorResponsesView.projectCode, + projectName: vendorResponsesView.projectName, + dueDate: vendorResponsesView.rfqDueDate, + createdBy: vendorResponsesView.rfqCreatedBy, + }) + .from(vendorResponsesView) + .where(eq(vendorResponsesView.rfqId, rfqId)) + + if (!rfqRow) { + throw new Error(`RFQ #${rfqId} not found`) + } + + // (B) RFQ 아이템 목록 + const items = await tx + .select({ + itemCode: rfqItems.itemCode, + description: rfqItems.description, + quantity: rfqItems.quantity, + uom: rfqItems.uom, + }) + .from(rfqItems) + .where(eq(rfqItems.rfqId, rfqId)) + + // (C) 대상 벤더들 + const vendorRows = await tx + .select({ id: vendors.id, email: vendors.email }) + .from(vendors) + .where(sql`${vendors.id} in (${vendorIds})`) + + // (D) 모든 TBE 파일 저장 & 이후 벤더 초대 처리 + // 파일은 한 번만 저장해도 되지만, 각 벤더별로 따로 저장/첨부가 필요하다면 루프를 돌려도 됨. + // 여기서는 "모든 파일"을 RFQ-DIR에 저장 + "각 벤더"에는 동일 파일 목록을 첨부한다는 예시. + const savedFiles = [] + for (const file of tbeFiles) { + const originalName = file.name || "tbe-sheet.xlsx" + const savePath = path.join(uploadDir, originalName) + + // 파일 ArrayBuffer → Buffer 변환 후 저장 + const arrayBuffer = await file.arrayBuffer() + fs.writeFile(savePath, Buffer.from(arrayBuffer)) + + // 저장 경로 & 파일명 기록 + savedFiles.push({ + fileName: originalName, + filePath: `/rfq/${rfqId}/${originalName}`, // public 이하 경로 + absolutePath: savePath, + }) + } + + // (E) 각 벤더별로 TBE 평가 레코드, 초대 처리, 메일 발송 + for (const v of vendorRows) { + if (!v.email) { + // 이메일 없는 경우 로직 (스킵 or throw) + continue + } + + // 1) TBE 평가 레코드 생성 + const [evalRow] = await tx + .insert(rfqEvaluations) + .values({ + rfqId, + vendorId: v.id, + evalType: "TBE", + }) + .returning({ id: rfqEvaluations.id }) + + // 2) rfqAttachments에 저장한 파일들을 기록 + for (const sf of savedFiles) { + await tx.insert(rfqAttachments).values({ + rfqId, + // vendorId: v.id, + evaluationId: evalRow.id, + fileName: sf.fileName, + filePath: sf.filePath, + }) + } + + // 4) 메일 발송 + const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://3.36.56.124:3000' + const loginUrl = `${baseUrl}/ko/partners/rfq` + await sendEmail({ + to: v.email, + subject: `[RFQ ${rfqRow.rfqCode}] You are invited for TBE!`, + template: "rfq-invite", + context: { + language: "en", + rfqId, + vendorId: v.id, + + rfqCode: rfqRow.rfqCode, + projectCode: rfqRow.projectCode, + projectName: rfqRow.projectName, + dueDate: rfqRow.dueDate, + description: rfqRow.description, + + items: items.map((it) => ({ + itemCode: it.itemCode, + description: it.description, + quantity: it.quantity, + uom: it.uom, + })), + loginUrl, + }, + attachments: savedFiles.map((sf) => ({ + path: sf.absolutePath, + filename: sf.fileName, + })), + }) + } + + // 5) 캐시 무효화 + revalidateTag("tbe-vendors") + }) + + // 성공 + return { error: null } + } catch (err) { + console.error("[inviteTbeVendorsAction] Error:", err) + return { error: getErrorMessage(err) } + } +} +////partners + + +export async function modifyRfqVendor(input: UpdateRfqVendorSchema) { + unstable_noStore(); + try { + const data = await db.transaction(async (tx) => { + const [res] = await updateRfqVendor(tx, input.id, { + responseStatus: input.status, + }); + return res; + }); + + revalidateTag("rfqs-vendor"); + revalidateTag("rfq-vendors"); + + return { data: null, error: null }; + } catch (err) { + return { data: null, error: getErrorMessage(err) }; + } +} + +export async function createRfqCommentWithAttachments(params: { + rfqId: number + vendorId?: number | null + commentText: string + commentedBy: number + evaluationId?: number | null + files?: File[] +}) { + const { rfqId, vendorId, commentText, commentedBy, evaluationId, files } = params + + + // 1) 새로운 코멘트 생성 + const [insertedComment] = await db + .insert(rfqComments) + .values({ + rfqId, + vendorId: vendorId || null, + commentText, + commentedBy, + evaluationId: evaluationId || null, + }) + .returning({ id: rfqComments.id, createdAt: rfqComments.createdAt }) // id만 반환하도록 + + if (!insertedComment) { + throw new Error("Failed to create comment") + } + + // 2) 첨부파일 처리 (S3 업로드 등은 프로젝트 상황에 따라) + if (files && files.length > 0) { + + const rfqDir = path.join(process.cwd(), "public", "rfq", String(rfqId)); + // 폴더 없으면 생성 + await fs.mkdir(rfqDir, { recursive: true }); + + for (const file of files) { + const ab = await file.arrayBuffer(); + const buffer = Buffer.from(ab); + + // 2-2) 고유 파일명 + const uniqueName = `${randomUUID()}-${file.name}`; + // 예) "rfq/123/xxx" + const relativePath = path.join("rfq", String(rfqId), uniqueName); + const absolutePath = path.join(process.cwd(), "public", relativePath); + + // 2-3) 파일 저장 + await fs.writeFile(absolutePath, buffer); + + // DB에 첨부파일 row 생성 + await db.insert(rfqAttachments).values({ + rfqId, + vendorId: vendorId || null, + evaluationId: evaluationId || null, + commentId: insertedComment.id, // 새 코멘트와 연결 + fileName: file.name, + filePath: "/" + relativePath.replace(/\\/g, "/"), + }) + } + } + + revalidateTag("rfq-vendors"); + + return { ok: true, commentId: insertedComment.id, createdAt: insertedComment.createdAt } +} + +export async function fetchRfqAttachmentsbyCommentId(commentId: number) { + // DB select + const rows = await db + .select() + .from(rfqAttachments) + .where(eq(rfqAttachments.commentId, commentId)) + + // rows: { id, fileName, filePath, createdAt, vendorId, ... } + // 필요 없는 필드는 omit하거나 transform 가능 + return rows.map((row) => ({ + id: row.id, + fileName: row.fileName, + filePath: row.filePath, + createdAt: row.createdAt, // or string + vendorId: row.vendorId, + evaluationId: row.evaluationId, + size: undefined, // size를 DB에 저장하지 않았다면 + })) +} + +export async function updateRfqComment(params: { + commentId: number + commentText: string +}) { + const { commentId, commentText } = params + + // 예: 간단한 길이 체크 등 유효성 검사 + if (!commentText || commentText.trim().length === 0) { + throw new Error("Comment text must not be empty.") + } + + // DB 업데이트 + const updatedRows = await db + .update(rfqComments) + .set({ commentText }) // 필요한 컬럼만 set + .where(eq(rfqComments.id, commentId)) + .returning({ id: rfqComments.id }) + + // 혹은 returning 전체(row)를 받아서 확인할 수도 있음 + if (updatedRows.length === 0) { + // 해당 id가 없으면 예외 + throw new Error("Comment not found or already deleted.") + } + revalidateTag("rfq-vendors"); + return { ok: true } +} + +export type Project = { + id: number; + projectCode: string; + projectName: string; +} + +export async function getProjects(): Promise<Project[]> { + try { + // 트랜잭션을 사용하여 프로젝트 데이터 조회 + const projectList = await db.transaction(async (tx) => { + // 모든 프로젝트 조회 + const results = await tx + .select({ + id: projects.id, + projectCode: projects.code, // 테이블의 실제 컬럼명에 맞게 조정 + projectName: projects.name, // 테이블의 실제 컬럼명에 맞게 조정 + }) + .from(projects) + .orderBy(projects.code); + + return results; + }); + + return projectList; + } catch (error) { + console.error("프로젝트 목록 가져오기 실패:", error); + return []; // 오류 발생 시 빈 배열 반환 + } +} + + +// 반환 타입 명시적 정의 - rfqCode가 null일 수 있음을 반영 +export interface BudgetaryRfq { + id: number; + rfqCode: string | null; // null 허용으로 변경 + description: string | null; + projectId: number | null; + projectCode: string | null; + projectName: string | null; +} + +interface GetBudgetaryRfqsParams { + search?: string; + projectId?: number; + limit?: number; + offset?: number; +} + +type GetBudgetaryRfqsResponse = + | { rfqs: BudgetaryRfq[]; totalCount: number; error?: never } + | { error: string; rfqs?: never; totalCount: number } +/** + * Budgetary 타입의 RFQ 목록을 가져오는 서버 액션 + * Purchase RFQ 생성 시 부모 RFQ로 선택할 수 있도록 함 + * 페이징 및 필터링 기능 포함 + */ +export async function getBudgetaryRfqs(params: GetBudgetaryRfqsParams = {}): Promise<GetBudgetaryRfqsResponse> { + const { search, projectId, limit = 50, offset = 0 } = params; + const cacheKey = `budgetary-rfqs-${JSON.stringify(params)}`; + return unstable_cache( + async () => { + try { + + const baseCondition = eq(rfqs.rfqType, RfqType.BUDGETARY); + + let where1 + // 검색어 조건 추가 (있을 경우) + if (search && search.trim()) { + const searchTerm = `%${search.trim()}%`; + const searchCondition = or( + ilike(rfqs.rfqCode, searchTerm), + ilike(rfqs.description, searchTerm), + ilike(projects.code, searchTerm), + ilike(projects.name, searchTerm) + ); + where1 = searchCondition + } + + let where2 + // 프로젝트 ID 조건 추가 (있을 경우) + if (projectId) { + where2 = eq(rfqs.projectId, projectId); + } + + const finalWhere = and(where1, where2, baseCondition) + + // 총 개수 조회 + const [countResult] = await db + .select({ count: count() }) + .from(rfqs) + .leftJoin(projects, eq(rfqs.projectId, projects.id)) + .where(finalWhere); + + // 실제 데이터 조회 + const budgetaryRfqs = await db + .select({ + id: rfqs.id, + rfqCode: rfqs.rfqCode, + description: rfqs.description, + projectId: rfqs.projectId, + projectCode: projects.code, + projectName: projects.name, + }) + .from(rfqs) + .leftJoin(projects, eq(rfqs.projectId, projects.id)) + .where(finalWhere) + .orderBy(desc(rfqs.createdAt)) + .limit(limit) + .offset(offset); + + return { + rfqs: budgetaryRfqs as BudgetaryRfq[], // 타입 단언으로 호환성 보장 + totalCount: Number(countResult?.count) || 0 + }; + } catch (error) { + console.error("Error fetching budgetary RFQs:", error); + return { + error: "Failed to fetch budgetary RFQs", + totalCount: 0 + }; + } + }, + [cacheKey], + { + revalidate: 60, // 1분 캐시 + tags: ["rfqs-budgetary"], + } + )(); +} + +export async function getAllVendors() { + // Adjust the query as needed (add WHERE, ORDER, etc.) + const allVendors = await db.select().from(vendors) + return allVendors +} + +/** + * Server action to associate items from an RFQ with a vendor + * + * @param rfqId - The ID of the RFQ containing items to associate + * @param vendorId - The ID of the vendor to associate items with + * @returns Object indicating success or failure + */ +export async function addItemToVendors(rfqId: number, vendorIds: number[]) { + try { + // Input validation + if (!vendorIds.length) { + return { + success: false, + error: "No vendors selected" + }; + } + + // 1. Find all itemCodes associated with the given rfqId using select + const rfqItemResults = await db + .select({ itemCode: rfqItems.itemCode }) + .from(rfqItems) + .where(eq(rfqItems.rfqId, rfqId)); + + // Extract itemCodes + const itemCodes = rfqItemResults.map(item => item.itemCode); + + if (itemCodes.length === 0) { + return { + success: false, + error: "No items found for this RFQ" + }; + } + + // 2. Find existing vendor-item combinations to avoid duplicates + const existingCombinations = await db + .select({ + vendorId: vendorPossibleItems.vendorId, + itemCode: vendorPossibleItems.itemCode + }) + .from(vendorPossibleItems) + .where( + and( + inArray(vendorPossibleItems.vendorId, vendorIds), + inArray(vendorPossibleItems.itemCode, itemCodes) + ) + ); + + // Create a Set of existing combinations for easy lookups + const existingSet = new Set(); + existingCombinations.forEach(combo => { + existingSet.add(`${combo.vendorId}-${combo.itemCode}`); + }); + + // 3. Prepare records to insert (only non-existing combinations) + const recordsToInsert = []; + + for (const vendorId of vendorIds) { + for (const itemCode of itemCodes) { + const key = `${vendorId}-${itemCode}`; + if (!existingSet.has(key)) { + recordsToInsert.push({ + vendorId, + itemCode, + // createdAt and updatedAt will be set by defaultNow() + }); + } + } + } + + // 4. Bulk insert if there are records to insert + let insertedCount = 0; + if (recordsToInsert.length > 0) { + const result = await db.insert(vendorPossibleItems).values(recordsToInsert); + insertedCount = recordsToInsert.length; + } + + // 5. Revalidate to refresh data + revalidateTag("rfq-vendors"); + + // 6. Return success with counts + return { + success: true, + insertedCount, + totalPossibleItems: vendorIds.length * itemCodes.length, + vendorCount: vendorIds.length, + itemCount: itemCodes.length + }; + } catch (error) { + console.error("Error adding items to vendors:", error); + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error" + }; + } +} + +/** + * 특정 평가에 대한 TBE 템플릿 파일 목록 조회 + * evaluationId가 일치하고 vendorId가 null인 파일 목록 + */ +export async function fetchTbeTemplateFiles(evaluationId: number) { + + console.log(evaluationId, "evaluationId") + try { + const files = await db + .select({ + id: rfqAttachments.id, + fileName: rfqAttachments.fileName, + filePath: rfqAttachments.filePath, + createdAt: rfqAttachments.createdAt, + }) + .from(rfqAttachments) + .where( + and( + isNull(rfqAttachments.commentId), + isNull(rfqAttachments.vendorId), + eq(rfqAttachments.evaluationId, evaluationId), + // eq(rfqAttachments.vendorId, vendorId), + + ) + ) + + return { files, error: null } + } catch (error) { + console.error("Error fetching TBE template files:", error) + return { + files: [], + error: "템플릿 파일을 가져오는 중 오류가 발생했습니다." + } + } +} + +/** + * 특정 TBE 템플릿 파일 다운로드를 위한 정보 조회 + */ +export async function getTbeTemplateFileInfo(fileId: number) { + try { + const file = await db + .select({ + fileName: rfqAttachments.fileName, + filePath: rfqAttachments.filePath, + }) + .from(rfqAttachments) + .where(eq(rfqAttachments.id, fileId)) + .limit(1) + + if (!file.length) { + return { file: null, error: "파일을 찾을 수 없습니다." } + } + + return { file: file[0], error: null } + } catch (error) { + console.error("Error getting TBE template file info:", error) + return { + file: null, + error: "파일 정보를 가져오는 중 오류가 발생했습니다." + } + } +} + +/** + * TBE 응답 파일 업로드 처리 + */ +export async function uploadTbeResponseFile(formData: FormData) { + try { + const file = formData.get("file") as File + const rfqId = parseInt(formData.get("rfqId") as string) + const vendorId = parseInt(formData.get("vendorId") as string) + const evaluationId = parseInt(formData.get("evaluationId") as string) + const vendorResponseId = parseInt(formData.get("vendorResponseId") as string) + + if (!file || !rfqId || !vendorId || !evaluationId) { + return { + success: false, + error: "필수 필드가 누락되었습니다." + } + } + + // 타임스탬프 기반 고유 파일명 생성 + const timestamp = Date.now() + const originalName = file.name + const fileExtension = originalName.split(".").pop() + const fileName = `${originalName.split(".")[0]}-${timestamp}.${fileExtension}` + + // 업로드 디렉토리 및 경로 정의 + const uploadDir = join(process.cwd(), "rfq", "tbe-responses") + + // 디렉토리가 없으면 생성 + try { + await mkdir(uploadDir, { recursive: true }) + } catch (error) { + // 이미 존재하면 무시 + } + + const filePath = join(uploadDir, fileName) + + // 파일을 버퍼로 변환 + const bytes = await file.arrayBuffer() + const buffer = Buffer.from(bytes) + + // 파일을 서버에 저장 + await writeFile(filePath, buffer) + + // 먼저 vendorTechnicalResponses 테이블에 엔트리 생성 + const technicalResponse = await db.insert(vendorTechnicalResponses) + .values({ + responseId: vendorResponseId, + summary: "TBE 응답 파일 업로드", // 필요에 따라 수정 + notes: `파일명: ${originalName}`, + }) + .returning({ id: vendorTechnicalResponses.id }); + + // 생성된 기술 응답 ID 가져오기 + const technicalResponseId = technicalResponse[0].id; + + // 파일 정보를 데이터베이스에 저장 + const dbFilePath = `/rfq/tbe-responses/${fileName}` + + // vendorResponseAttachments 테이블 스키마에 맞게 데이터 삽입 + await db.insert(vendorResponseAttachments) + .values({ + // 오류 메시지를 기반으로 올바른 필드 이름 사용 + // 테이블 스키마에 정의된 필드만 포함해야 함 + responseId: vendorResponseId, + technicalResponseId: technicalResponseId, + // vendorId와 evaluationId 필드가 테이블에 있다면 포함, 없다면 제거 + // vendorId: vendorId, + // evaluationId: evaluationId, + fileName: originalName, + filePath: dbFilePath, + uploadedAt: new Date(), + }); + + // 경로 재검증 (캐시된 데이터 새로고침) + revalidatePath(`/rfq/${rfqId}/tbe`) + revalidateTag(`tbe-vendors-${vendorId}`) + + return { + success: true, + message: "파일이 성공적으로 업로드되었습니다." + } + } catch (error) { + console.error("Error uploading file:", error) + return { + success: false, + error: "파일 업로드에 실패했습니다." + } + } +} + +export async function getTbeSubmittedFiles(responseId: number) { + try { + // First, get the technical response IDs where vendorResponseId matches responseId + const technicalResponses = await db + .select({ + id: vendorTechnicalResponses.id, + }) + .from(vendorTechnicalResponses) + .where( + eq(vendorTechnicalResponses.responseId, responseId) + ) + + if (technicalResponses.length === 0) { + return { files: [], error: null } + } + + // Extract the IDs from the result + const technicalResponseIds = technicalResponses.map(tr => tr.id) + + // Then get attachments where technicalResponseId matches any of the IDs we found + const files = await db + .select({ + id: vendorResponseAttachments.id, + fileName: vendorResponseAttachments.fileName, + filePath: vendorResponseAttachments.filePath, + uploadedAt: vendorResponseAttachments.uploadedAt, + fileType: vendorResponseAttachments.fileType, + attachmentType: vendorResponseAttachments.attachmentType, + description: vendorResponseAttachments.description, + }) + .from(vendorResponseAttachments) + .where( + inArray(vendorResponseAttachments.technicalResponseId, technicalResponseIds) + ) + .orderBy(vendorResponseAttachments.uploadedAt) + + return { files, error: null } + } catch (error) { + return { files: [], error: 'Failed to fetch TBE submitted files' } + } +} + + + +export async function getTbeFilesForVendor(rfqId: number, vendorId: number) { + try { + // Step 1: Get responseId from vendor_responses table + const response = await db + .select({ + id: vendorResponses.id, + }) + .from(vendorResponses) + .where( + and( + eq(vendorResponses.rfqId, rfqId), + eq(vendorResponses.vendorId, vendorId) + ) + ) + .limit(1); + + if (!response || response.length === 0) { + return { files: [], error: 'No vendor response found' }; + } + + const responseId = response[0].id; + + // Step 2: Get the technical response IDs + const technicalResponses = await db + .select({ + id: vendorTechnicalResponses.id, + }) + .from(vendorTechnicalResponses) + .where( + eq(vendorTechnicalResponses.responseId, responseId) + ); + + if (technicalResponses.length === 0) { + return { files: [], error: null }; + } + + // Extract the IDs from the result + const technicalResponseIds = technicalResponses.map(tr => tr.id); + + // Step 3: Get attachments where technicalResponseId matches any of the IDs + const files = await db + .select({ + id: vendorResponseAttachments.id, + fileName: vendorResponseAttachments.fileName, + filePath: vendorResponseAttachments.filePath, + uploadedAt: vendorResponseAttachments.uploadedAt, + fileType: vendorResponseAttachments.fileType, + attachmentType: vendorResponseAttachments.attachmentType, + description: vendorResponseAttachments.description, + }) + .from(vendorResponseAttachments) + .where( + inArray(vendorResponseAttachments.technicalResponseId, technicalResponseIds) + ) + .orderBy(vendorResponseAttachments.uploadedAt); + + return { files, error: null }; + } catch (error) { + return { files: [], error: 'Failed to fetch vendor files' }; + } +} + +export async function getAllTBE(input: GetTBESchema) { + return unstable_cache( + async () => { + // 1) 페이징 + const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10) + const limit = input.perPage ?? 10 + + // 2) 고급 필터 + const advancedWhere = filterColumns({ + table: vendorTbeView, + filters: input.filters ?? [], + joinOperator: input.joinOperator ?? "and", + }) + + // 3) 글로벌 검색 + let globalWhere + if (input.search) { + const s = `%${input.search}%` + globalWhere = or( + sql`${vendorTbeView.vendorName} ILIKE ${s}`, + sql`${vendorTbeView.vendorCode} ILIKE ${s}`, + sql`${vendorTbeView.email} ILIKE ${s}`, + sql`${vendorTbeView.rfqCode} ILIKE ${s}`, + sql`${vendorTbeView.projectCode} ILIKE ${s}`, + sql`${vendorTbeView.projectName} ILIKE ${s}` + ) + } + + // 4) REJECTED 아니거나 NULL + const notRejected = or( + ne(vendorTbeView.rfqVendorStatus, "REJECTED"), + isNull(vendorTbeView.rfqVendorStatus) + ) + + // 5) rfqType 필터 추가 + const rfqTypeFilter = input.rfqType ? eq(vendorTbeView.rfqType, input.rfqType) : undefined + + // 6) finalWhere - rfqType 필터 추가 + const finalWhere = and( + notRejected, + advancedWhere, + globalWhere, + rfqTypeFilter // 새로 추가된 rfqType 필터 + ) + + // 6) 정렬 + const orderBy = input.sort?.length + ? input.sort.map((s) => { + const col = (vendorTbeView as any)[s.id] + return s.desc ? desc(col) : asc(col) + }) + : [desc(vendorTbeView.rfqId), asc(vendorTbeView.vendorId)] // Default sort by newest RFQ first + + // 7) 메인 SELECT + const [rows, total] = await db.transaction(async (tx) => { + const data = await tx + .select({ + // 원하는 컬럼들 + id: vendorTbeView.vendorId, + tbeId: vendorTbeView.tbeId, + vendorId: vendorTbeView.vendorId, + vendorName: vendorTbeView.vendorName, + vendorCode: vendorTbeView.vendorCode, + address: vendorTbeView.address, + country: vendorTbeView.country, + email: vendorTbeView.email, + website: vendorTbeView.website, + vendorStatus: vendorTbeView.vendorStatus, + + rfqId: vendorTbeView.rfqId, + rfqCode: vendorTbeView.rfqCode, + projectCode: vendorTbeView.projectCode, + projectName: vendorTbeView.projectName, + description: vendorTbeView.description, + dueDate: vendorTbeView.dueDate, + + rfqVendorStatus: vendorTbeView.rfqVendorStatus, + rfqVendorUpdated: vendorTbeView.rfqVendorUpdated, + + tbeResult: vendorTbeView.tbeResult, + tbeNote: vendorTbeView.tbeNote, + tbeUpdated: vendorTbeView.tbeUpdated, + }) + .from(vendorTbeView) + .where(finalWhere) + .orderBy(...orderBy) + .offset(offset) + .limit(limit) + + const [{ count }] = await tx + .select({ count: sql<number>`count(*)`.as("count") }) + .from(vendorTbeView) + .where(finalWhere) + + return [data, Number(count)] + }) + + if (!rows.length) { + return { data: [], pageCount: 0 } + } + + // 8) Get distinct rfqIds and vendorIds - filter out nulls + const distinctVendorIds = [...new Set(rows.map((r) => r.vendorId).filter(Boolean))] as number[]; + const distinctRfqIds = [...new Set(rows.map((r) => r.rfqId).filter(Boolean))] as number[]; + + // 9) Comments 조회 + const commentsConditions = [isNotNull(rfqComments.evaluationId)]; + + // 배열이 비어있지 않을 때만 조건 추가 + if (distinctRfqIds.length > 0) { + commentsConditions.push(inArray(rfqComments.rfqId, distinctRfqIds)); + } + + if (distinctVendorIds.length > 0) { + commentsConditions.push(inArray(rfqComments.vendorId, distinctVendorIds)); + } + + const commAll = await db + .select({ + id: rfqComments.id, + commentText: rfqComments.commentText, + vendorId: rfqComments.vendorId, + rfqId: rfqComments.rfqId, + evaluationId: rfqComments.evaluationId, + createdAt: rfqComments.createdAt, + commentedBy: rfqComments.commentedBy, + evalType: rfqEvaluations.evalType, + }) + .from(rfqComments) + .innerJoin( + rfqEvaluations, + and( + eq(rfqEvaluations.id, rfqComments.evaluationId), + eq(rfqEvaluations.evalType, "TBE") + ) + ) + .where(and(...commentsConditions)); + + // 9-A) Create a composite key (rfqId-vendorId) -> comments mapping + const commByCompositeKey = new Map<string, any[]>() + for (const c of commAll) { + if (!c.rfqId || !c.vendorId) continue; + + const compositeKey = `${c.rfqId}-${c.vendorId}`; + if (!commByCompositeKey.has(compositeKey)) { + commByCompositeKey.set(compositeKey, []) + } + commByCompositeKey.get(compositeKey)!.push({ + id: c.id, + commentText: c.commentText, + vendorId: c.vendorId, + evaluationId: c.evaluationId, + createdAt: c.createdAt, + commentedBy: c.commentedBy, + }) + } + + // 10) Responses 조회 + const responsesAll = await db + .select({ + id: vendorResponses.id, + rfqId: vendorResponses.rfqId, + vendorId: vendorResponses.vendorId + }) + .from(vendorResponses) + .where( + and( + inArray(vendorResponses.rfqId, distinctRfqIds), + inArray(vendorResponses.vendorId, distinctVendorIds) + ) + ); + + // Group responses by rfqId-vendorId composite key + const responsesByCompositeKey = new Map<string, number[]>(); + for (const resp of responsesAll) { + const compositeKey = `${resp.rfqId}-${resp.vendorId}`; + if (!responsesByCompositeKey.has(compositeKey)) { + responsesByCompositeKey.set(compositeKey, []); + } + responsesByCompositeKey.get(compositeKey)!.push(resp.id); + } + + // Get all responseIds + const allResponseIds = responsesAll.map(r => r.id); + + // 11) Get technicalResponses for these responseIds + const technicalResponsesAll = await db + .select({ + id: vendorTechnicalResponses.id, + responseId: vendorTechnicalResponses.responseId + }) + .from(vendorTechnicalResponses) + .where(inArray(vendorTechnicalResponses.responseId, allResponseIds)); + + // Create mapping from responseId to technicalResponseIds + const technicalResponseIdsByResponseId = new Map<number, number[]>(); + for (const tr of technicalResponsesAll) { + if (!technicalResponseIdsByResponseId.has(tr.responseId)) { + technicalResponseIdsByResponseId.set(tr.responseId, []); + } + technicalResponseIdsByResponseId.get(tr.responseId)!.push(tr.id); + } + + // Get all technicalResponseIds + const allTechnicalResponseIds = technicalResponsesAll.map(tr => tr.id); + + // 12) Get attachments for these technicalResponseIds + const filesAll = await db + .select({ + id: vendorResponseAttachments.id, + fileName: vendorResponseAttachments.fileName, + filePath: vendorResponseAttachments.filePath, + technicalResponseId: vendorResponseAttachments.technicalResponseId, + fileType: vendorResponseAttachments.fileType, + attachmentType: vendorResponseAttachments.attachmentType, + description: vendorResponseAttachments.description, + uploadedAt: vendorResponseAttachments.uploadedAt, + uploadedBy: vendorResponseAttachments.uploadedBy + }) + .from(vendorResponseAttachments) + .where( + and( + inArray(vendorResponseAttachments.technicalResponseId, allTechnicalResponseIds), + isNotNull(vendorResponseAttachments.technicalResponseId) + ) + ); + + // Create mapping from technicalResponseId to attachments + const filesByTechnicalResponseId = new Map<number, any[]>(); + for (const file of filesAll) { + if (file.technicalResponseId === null) continue; + + if (!filesByTechnicalResponseId.has(file.technicalResponseId)) { + filesByTechnicalResponseId.set(file.technicalResponseId, []); + } + filesByTechnicalResponseId.get(file.technicalResponseId)!.push({ + id: file.id, + fileName: file.fileName, + filePath: file.filePath, + fileType: file.fileType, + attachmentType: file.attachmentType, + description: file.description, + uploadedAt: file.uploadedAt, + uploadedBy: file.uploadedBy + }); + } + + // 13) Create the final filesByCompositeKey map + const filesByCompositeKey = new Map<string, any[]>(); + + for (const [compositeKey, responseIds] of responsesByCompositeKey.entries()) { + filesByCompositeKey.set(compositeKey, []); + + for (const responseId of responseIds) { + const technicalResponseIds = technicalResponseIdsByResponseId.get(responseId) || []; + + for (const technicalResponseId of technicalResponseIds) { + const files = filesByTechnicalResponseId.get(technicalResponseId) || []; + filesByCompositeKey.get(compositeKey)!.push(...files); + } + } + } + + // 14) 최종 합치기 + const final = rows.map((row) => { + const compositeKey = `${row.rfqId}-${row.vendorId}`; + + return { + ...row, + dueDate: row.dueDate ? new Date(row.dueDate) : null, + comments: commByCompositeKey.get(compositeKey) ?? [], + files: filesByCompositeKey.get(compositeKey) ?? [], + }; + }) + + const pageCount = Math.ceil(total / limit) + return { data: final, pageCount } + }, + [JSON.stringify(input)], + { + revalidate: 3600, + tags: ["all-tbe-vendors"], + } + )() +} + + + + + +export async function getCBE(input: GetCBESchema, rfqId: number) { + return unstable_cache( + async () => { + // [1] 페이징 + const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10); + const limit = input.perPage ?? 10; + + // [2] 고급 필터 + const advancedWhere = filterColumns({ + table: vendorCbeView, + filters: input.filters ?? [], + joinOperator: input.joinOperator ?? "and", + }); + + // [3] 글로벌 검색 + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + sql`${vendorCbeView.vendorName} ILIKE ${s}`, + sql`${vendorCbeView.vendorCode} ILIKE ${s}`, + sql`${vendorCbeView.email} ILIKE ${s}` + ); + } + + // [4] REJECTED 아니거나 NULL + const notRejected = or( + ne(vendorCbeView.rfqVendorStatus, "REJECTED"), + isNull(vendorCbeView.rfqVendorStatus) + ); + + // [5] 최종 where + const finalWhere = and( + eq(vendorCbeView.rfqId, rfqId), + notRejected, + advancedWhere, + globalWhere + ); + + // [6] 정렬 + const orderBy = input.sort?.length + ? input.sort.map((s) => { + // vendor_cbe_view 컬럼 중 정렬 대상이 되는 것만 매핑 + const col = (vendorCbeView as any)[s.id]; + return s.desc ? desc(col) : asc(col); + }) + : [asc(vendorCbeView.vendorId)]; + + // [7] 메인 SELECT + const [rows, total] = await db.transaction(async (tx) => { + const data = await tx + .select({ + // 필요한 컬럼만 추출 + id: vendorCbeView.vendorId, + cbeId: vendorCbeView.cbeId, + vendorId: vendorCbeView.vendorId, + vendorName: vendorCbeView.vendorName, + vendorCode: vendorCbeView.vendorCode, + address: vendorCbeView.address, + country: vendorCbeView.country, + email: vendorCbeView.email, + website: vendorCbeView.website, + vendorStatus: vendorCbeView.vendorStatus, + + rfqId: vendorCbeView.rfqId, + rfqCode: vendorCbeView.rfqCode, + projectCode: vendorCbeView.projectCode, + projectName: vendorCbeView.projectName, + description: vendorCbeView.description, + dueDate: vendorCbeView.dueDate, + + rfqVendorStatus: vendorCbeView.rfqVendorStatus, + rfqVendorUpdated: vendorCbeView.rfqVendorUpdated, + + cbeResult: vendorCbeView.cbeResult, + cbeNote: vendorCbeView.cbeNote, + cbeUpdated: vendorCbeView.cbeUpdated, + + // 상업평가 정보 + totalCost: vendorCbeView.totalCost, + currency: vendorCbeView.currency, + paymentTerms: vendorCbeView.paymentTerms, + incoterms: vendorCbeView.incoterms, + deliverySchedule: vendorCbeView.deliverySchedule, + }) + .from(vendorCbeView) + .where(finalWhere) + .orderBy(...orderBy) + .offset(offset) + .limit(limit); + + const [{ count }] = await tx + .select({ count: sql<number>`count(*)`.as("count") }) + .from(vendorCbeView) + .where(finalWhere); + + return [data, Number(count)]; + }); + + if (!rows.length) { + return { data: [], pageCount: 0 }; + } + + // [8] Comments 조회 + // TBE 에서는 rfqComments + rfqEvaluations(evalType="TBE") 를 조인했지만, + // CBE는 cbeEvaluations 또는 evalType="CBE"를 기준으로 바꾸면 됩니다. + // 만약 cbeEvaluations.id 를 evaluationId 로 참조한다면 아래와 같이 innerJoin: + const distinctVendorIds = [...new Set(rows.map((r) => r.vendorId))]; + + const commAll = await db + .select({ + id: rfqComments.id, + commentText: rfqComments.commentText, + vendorId: rfqComments.vendorId, + evaluationId: rfqComments.evaluationId, + createdAt: rfqComments.createdAt, + commentedBy: rfqComments.commentedBy, + // cbeEvaluations에는 evalType 컬럼이 별도로 없을 수도 있음(프로젝트 구조에 맞게 수정) + // evalType: cbeEvaluations.evalType, + }) + .from(rfqComments) + .innerJoin( + cbeEvaluations, + eq(cbeEvaluations.id, rfqComments.evaluationId) + ) + .where( + and( + isNotNull(rfqComments.evaluationId), + eq(rfqComments.rfqId, rfqId), + inArray(rfqComments.vendorId, distinctVendorIds) + ) + ); + + // vendorId -> comments grouping + const commByVendorId = new Map<number, any[]>(); + for (const c of commAll) { + const vid = c.vendorId!; + if (!commByVendorId.has(vid)) { + commByVendorId.set(vid, []); + } + commByVendorId.get(vid)!.push({ + id: c.id, + commentText: c.commentText, + vendorId: c.vendorId, + evaluationId: c.evaluationId, + createdAt: c.createdAt, + commentedBy: c.commentedBy, + }); + } + + // [9] CBE 파일 조회 (프로젝트에 따라 구조가 달라질 수 있음) + // - TBE는 vendorTechnicalResponses 기준 + // - CBE는 vendorCommercialResponses(가정) 등이 있을 수 있음 + // - 여기서는 예시로 "동일한 vendorResponses + vendorResponseAttachments" 라고 가정 + // Step 1: vendorResponses 가져오기 (rfqId + vendorIds) + const responsesAll = await db + .select({ + id: vendorResponses.id, + vendorId: vendorResponses.vendorId, + }) + .from(vendorResponses) + .where( + and( + eq(vendorResponses.rfqId, rfqId), + inArray(vendorResponses.vendorId, distinctVendorIds) + ) + ); + + // Group responses by vendorId + const responsesByVendorId = new Map<number, number[]>(); + for (const resp of responsesAll) { + if (!responsesByVendorId.has(resp.vendorId)) { + responsesByVendorId.set(resp.vendorId, []); + } + responsesByVendorId.get(resp.vendorId)!.push(resp.id); + } + + // Step 2: responseIds + const allResponseIds = responsesAll.map((r) => r.id); + + + const commercialResponsesAll = await db + .select({ + id: vendorCommercialResponses.id, + responseId: vendorCommercialResponses.responseId, + }) + .from(vendorCommercialResponses) + .where(inArray(vendorCommercialResponses.responseId, allResponseIds)); + + const commercialResponseIdsByResponseId = new Map<number, number[]>(); + for (const cr of commercialResponsesAll) { + if (!commercialResponseIdsByResponseId.has(cr.responseId)) { + commercialResponseIdsByResponseId.set(cr.responseId, []); + } + commercialResponseIdsByResponseId.get(cr.responseId)!.push(cr.id); + } + + const allCommercialResponseIds = commercialResponsesAll.map((cr) => cr.id); + + + // 여기서는 예시로 TBE와 마찬가지로 vendorResponseAttachments를 + // 직접 responseId로 관리한다고 가정(혹은 commercialResponseId로 연결) + // Step 3: vendorResponseAttachments 조회 + const filesAll = await db + .select({ + id: vendorResponseAttachments.id, + fileName: vendorResponseAttachments.fileName, + filePath: vendorResponseAttachments.filePath, + responseId: vendorResponseAttachments.responseId, + fileType: vendorResponseAttachments.fileType, + attachmentType: vendorResponseAttachments.attachmentType, + description: vendorResponseAttachments.description, + uploadedAt: vendorResponseAttachments.uploadedAt, + uploadedBy: vendorResponseAttachments.uploadedBy, + }) + .from(vendorResponseAttachments) + .where( + and( + inArray(vendorResponseAttachments.responseId, allCommercialResponseIds), + isNotNull(vendorResponseAttachments.responseId) + ) + ); + + // Step 4: responseId -> files + const filesByResponseId = new Map<number, any[]>(); + for (const file of filesAll) { + const rid = file.responseId!; + if (!filesByResponseId.has(rid)) { + filesByResponseId.set(rid, []); + } + filesByResponseId.get(rid)!.push({ + id: file.id, + fileName: file.fileName, + filePath: file.filePath, + fileType: file.fileType, + attachmentType: file.attachmentType, + description: file.description, + uploadedAt: file.uploadedAt, + uploadedBy: file.uploadedBy, + }); + } + + // Step 5: vendorId -> files + const filesByVendorId = new Map<number, any[]>(); + for (const [vendorId, responseIds] of responsesByVendorId.entries()) { + filesByVendorId.set(vendorId, []); + for (const responseId of responseIds) { + const files = filesByResponseId.get(responseId) || []; + filesByVendorId.get(vendorId)!.push(...files); + } + } + + // [10] 최종 데이터 합치기 + const final = rows.map((row) => ({ + ...row, + dueDate: row.dueDate ? new Date(row.dueDate) : null, + comments: commByVendorId.get(row.vendorId) ?? [], + files: filesByVendorId.get(row.vendorId) ?? [], + })); + + const pageCount = Math.ceil(total / limit); + return { data: final, pageCount }; + }, + // 캐싱 키 & 옵션 + [JSON.stringify({ input, rfqId })], + { + revalidate: 3600, + tags: ["cbe-vendors"], + } + )(); +}
\ No newline at end of file diff --git a/lib/rfqs/table/BudgetaryRfqSelector.tsx b/lib/rfqs/table/BudgetaryRfqSelector.tsx new file mode 100644 index 00000000..cea53c1d --- /dev/null +++ b/lib/rfqs/table/BudgetaryRfqSelector.tsx @@ -0,0 +1,261 @@ +"use client" + +import * as React from "react" +import { Check, ChevronsUpDown, Loader } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@/components/ui/command" +import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover" +import { cn } from "@/lib/utils" +import { useDebounce } from "@/hooks/use-debounce" +import { getBudgetaryRfqs, type BudgetaryRfq } from "../service" + +interface BudgetaryRfqSelectorProps { + selectedRfqId?: number; + onRfqSelect: (rfq: BudgetaryRfq | null) => void; + placeholder?: string; +} + +export function BudgetaryRfqSelector({ + selectedRfqId, + onRfqSelect, + placeholder = "Budgetary RFQ 선택..." +}: BudgetaryRfqSelectorProps) { + const [searchTerm, setSearchTerm] = React.useState(""); + const debouncedSearchTerm = useDebounce(searchTerm, 300); + + const [open, setOpen] = React.useState(false); + const [loading, setLoading] = React.useState(false); + const [budgetaryRfqs, setBudgetaryRfqs] = React.useState<BudgetaryRfq[]>([]); + const [selectedRfq, setSelectedRfq] = React.useState<BudgetaryRfq | null>(null); + const [page, setPage] = React.useState(1); + const [hasMore, setHasMore] = React.useState(true); + const [totalCount, setTotalCount] = React.useState(0); + + const listRef = React.useRef<HTMLDivElement>(null); + + // 초기 선택된 RFQ가 있을 경우 로드 + React.useEffect(() => { + if (selectedRfqId && open) { + const loadSelectedRfq = async () => { + try { + const result = await getBudgetaryRfqs({ + limit: 1, + // null을 undefined로 변환하여 타입 오류 해결 + projectId: selectedRfq?.projectId ?? undefined + }); + + if ('rfqs' in result && result.rfqs) { + // 옵셔널 체이닝 또는 조건부 검사로 undefined 체크 + const foundRfq = result.rfqs.find(rfq => rfq.id === selectedRfqId); + if (foundRfq) { + setSelectedRfq(foundRfq); + } + } + } catch (error) { + console.error("선택된 RFQ 로드 오류:", error); + } + }; + + if (!selectedRfq || selectedRfq.id !== selectedRfqId) { + loadSelectedRfq(); + } + } + }, [selectedRfqId, open, selectedRfq]); + + // 검색어 변경 시 데이터 리셋 및 재로드 + React.useEffect(() => { + if (open) { + setPage(1); + setHasMore(true); + setBudgetaryRfqs([]); + loadBudgetaryRfqs(1, true); + } + }, [debouncedSearchTerm, open]); + + // 데이터 로드 함수 + const loadBudgetaryRfqs = async (pageToLoad: number, reset = false) => { + if (!open) return; + + setLoading(true); + try { + const limit = 20; // 한 번에 로드할 항목 수 + const result = await getBudgetaryRfqs({ + search: debouncedSearchTerm, + limit, + offset: (pageToLoad - 1) * limit, + }); + + if ('rfqs' in result && result.rfqs) { + if (reset) { + setBudgetaryRfqs(result.rfqs); + } else { + setBudgetaryRfqs(prev => [...prev, ...result.rfqs]); + } + + setTotalCount(result.totalCount); + setHasMore(result.rfqs.length === limit && (pageToLoad * limit) < result.totalCount); + setPage(pageToLoad); + } + } catch (error) { + console.error("Budgetary RFQs 로드 오류:", error); + } finally { + setLoading(false); + } + }; + + // 무한 스크롤 처리 + const handleScroll = () => { + if (listRef.current) { + const { scrollTop, scrollHeight, clientHeight } = listRef.current; + + // 스크롤이 90% 이상 내려갔을 때 다음 페이지 로드 + if (scrollTop + clientHeight >= scrollHeight * 0.9 && !loading && hasMore) { + loadBudgetaryRfqs(page + 1); + } + } + }; + + // RFQ를 프로젝트별로 그룹화하는 함수 + const groupRfqsByProject = (rfqs: BudgetaryRfq[]) => { + const groups: Record<string, { + projectId: number | null; + projectCode: string | null; + projectName: string | null; + rfqs: BudgetaryRfq[]; + }> = {}; + + // 'No Project' 그룹 기본 생성 + groups['no-project'] = { + projectId: null, + projectCode: null, + projectName: null, + rfqs: [] + }; + + // 프로젝트별로 RFQ 그룹화 + rfqs.forEach(rfq => { + const key = rfq.projectId ? `project-${rfq.projectId}` : 'no-project'; + + if (!groups[key] && rfq.projectId) { + groups[key] = { + projectId: rfq.projectId, + projectCode: rfq.projectCode, + projectName: rfq.projectName, + rfqs: [] + }; + } + + groups[key].rfqs.push(rfq); + }); + + // 필터링된 결과가 있는 그룹만 남기기 + return Object.values(groups).filter(group => group.rfqs.length > 0); + }; + + // 그룹화된 RFQ 목록 + const groupedRfqs = React.useMemo(() => { + return groupRfqsByProject(budgetaryRfqs); + }, [budgetaryRfqs]); + + // RFQ 선택 처리 + const handleRfqSelect = (rfq: BudgetaryRfq | null) => { + setSelectedRfq(rfq); + onRfqSelect(rfq); + setOpen(false); + }; + + return ( + <Popover open={open} onOpenChange={setOpen}> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={open} + className="w-full justify-between" + > + {selectedRfq + ? `${selectedRfq.rfqCode || ""} - ${selectedRfq.description || ""}` + : placeholder} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-[400px] p-0"> + <Command> + <CommandInput + placeholder="Budgetary RFQ 코드/설명/프로젝트 검색..." + value={searchTerm} + onValueChange={setSearchTerm} + /> + <CommandList + className="max-h-[300px]" + ref={listRef} + onScroll={handleScroll} + > + <CommandEmpty>검색 결과가 없습니다</CommandEmpty> + + <CommandGroup> + <CommandItem + value="none" + onSelect={() => handleRfqSelect(null)} + > + <Check + className={cn( + "mr-2 h-4 w-4", + !selectedRfq + ? "opacity-100" + : "opacity-0" + )} + /> + <span className="font-medium">선택 안함</span> + </CommandItem> + </CommandGroup> + + {groupedRfqs.map((group, index) => ( + <CommandGroup + key={`group-${group.projectId || index}`} + heading={ + group.projectId + ? `${group.projectCode || ""} - ${group.projectName || ""}` + : "프로젝트 없음" + } + > + {group.rfqs.map((rfq) => ( + <CommandItem + key={rfq.id} + value={`${rfq.rfqCode || ""} ${rfq.description || ""}`} + onSelect={() => handleRfqSelect(rfq)} + > + <Check + className={cn( + "mr-2 h-4 w-4", + selectedRfq?.id === rfq.id + ? "opacity-100" + : "opacity-0" + )} + /> + <span className="font-medium">{rfq.rfqCode || ""}</span> + <span className="ml-2 text-gray-500 truncate"> + - {rfq.description || ""} + </span> + </CommandItem> + ))} + </CommandGroup> + ))} + + {loading && ( + <div className="py-2 text-center"> + <Loader className="h-4 w-4 animate-spin mx-auto" /> + </div> + )} + + {!loading && !hasMore && budgetaryRfqs.length > 0 && ( + <div className="py-2 text-center text-sm text-muted-foreground"> + 총 {totalCount}개 중 {budgetaryRfqs.length}개 표시됨 + </div> + )} + </CommandList> + </Command> + </PopoverContent> + </Popover> + ); +}
\ No newline at end of file diff --git a/lib/rfqs/table/ItemsDialog.tsx b/lib/rfqs/table/ItemsDialog.tsx new file mode 100644 index 00000000..f1dbf90e --- /dev/null +++ b/lib/rfqs/table/ItemsDialog.tsx @@ -0,0 +1,744 @@ +"use client" + +import * as React from "react" +import { useForm, useFieldArray, useWatch } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { z } from "zod" + +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@/components/ui/form" +import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover" +import { + Command, + CommandInput, + CommandList, + CommandItem, + CommandGroup, + CommandEmpty +} from "@/components/ui/command" +import { Check, ChevronsUpDown, Plus, Trash2, Save, X, AlertCircle, Eye } from "lucide-react" +import { toast } from "sonner" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" +import { Badge } from "@/components/ui/badge" + +import { createRfqItem, deleteRfqItem } from "../service" +import { RfqWithItemCount } from "@/db/schema/rfq" +import { RfqType } from "../validations" + +// Zod 스키마 - 수량은 string으로 받아서 나중에 변환 +const itemSchema = z.object({ + id: z.number().optional(), + itemCode: z.string().nonempty({ message: "아이템 코드를 선택해주세요" }), + description: z.string().optional(), + quantity: z.coerce.number().min(1, { message: "최소 수량은 1입니다" }).default(1), + uom: z.string().default("each"), +}); + +const itemsFormSchema = z.object({ + rfqId: z.number().int(), + items: z.array(itemSchema).min(1, { message: "최소 1개 이상의 아이템을 추가해주세요" }), +}); + +type ItemsFormSchema = z.infer<typeof itemsFormSchema>; + +interface RfqsItemsDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + rfq: RfqWithItemCount | null; + defaultItems?: { + id?: number; + itemCode: string; + quantity?: number | null; + description?: string | null; + uom?: string | null; + }[]; + itemsList: { code: string | null; name: string }[]; + rfqType?: RfqType; +} + +export function RfqsItemsDialog({ + open, + onOpenChange, + rfq, + defaultItems = [], + itemsList, + rfqType +}: RfqsItemsDialogProps) { + const rfqId = rfq?.rfqId ?? 0; + + // 편집 가능 여부 확인 - DRAFT 상태일 때만 편집 가능 + const isEditable = rfq?.status === "DRAFT"; + + // 초기 아이템 ID 목록을 추적하기 위한 상태 추가 + const [initialItemIds, setInitialItemIds] = React.useState<(number | undefined)[]>([]); + + // 삭제된 아이템 ID를 저장하는 상태 추가 + const [deletedItemIds, setDeletedItemIds] = React.useState<number[]>([]); + + // 1) form + const form = useForm<ItemsFormSchema>({ + resolver: zodResolver(itemsFormSchema), + defaultValues: { + rfqId, + items: defaultItems.length > 0 ? defaultItems.map((it) => ({ + id: it.id, + quantity: it.quantity ?? 1, + uom: it.uom ?? "each", + itemCode: it.itemCode ?? "", + description: it.description ?? "", + })) : [{ itemCode: "", description: "", quantity: 1, uom: "each" }], + }, + mode: "onChange", // 입력 필드가 변경될 때마다 유효성 검사 + }); + + // 다이얼로그가 열릴 때마다 폼 초기화 및 초기 아이템 ID 저장 + React.useEffect(() => { + if (open) { + const initialItems = defaultItems.length > 0 + ? defaultItems.map((it) => ({ + id: it.id, + quantity: it.quantity ?? 1, + uom: it.uom ?? "each", + itemCode: it.itemCode ?? "", + description: it.description ?? "", + })) + : [{ itemCode: "", description: "", quantity: 1, uom: "each" }]; + + form.reset({ + rfqId, + items: initialItems, + }); + + // 초기 아이템 ID 목록 저장 + setInitialItemIds(defaultItems.map(item => item.id)); + + // 삭제된 아이템 목록 초기화 + setDeletedItemIds([]); + setHasUnsavedChanges(false); + } + }, [open, defaultItems, rfqId, form]); + + // 새로운 요소에 대한 ref 배열 + const inputRefs = React.useRef<Array<HTMLButtonElement | null>>([]); + const [isSubmitting, setIsSubmitting] = React.useState(false); + const [hasUnsavedChanges, setHasUnsavedChanges] = React.useState(false); + const [isExitDialogOpen, setIsExitDialogOpen] = React.useState(false); + + // 폼 변경 감지 - 편집 가능한 경우에만 변경 감지 + React.useEffect(() => { + if (!isEditable) return; + + const subscription = form.watch(() => { + setHasUnsavedChanges(true); + }); + return () => subscription.unsubscribe(); + }, [form, isEditable]); + + // 2) field array + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: "items", + }); + + // 3) watch items array + const watchItems = form.watch("items"); + + // 4) Add item row with auto-focus + function handleAddItem() { + if (!isEditable) return; + + // 명시적으로 숫자 타입으로 지정 + append({ + itemCode: "", + description: "", + quantity: 1, + uom: "each" + }); + setHasUnsavedChanges(true); + + // 다음 렌더링 사이클에서 새로 추가된 항목에 포커스 + setTimeout(() => { + const newIndex = fields.length; + const button = inputRefs.current[newIndex]; + if (button) { + button.click(); + } + }, 100); + } + + // 항목 직접 삭제 - 기존 ID가 있을 경우 삭제 목록에 추가 + const handleRemoveItem = (index: number) => { + if (!isEditable) return; + + const itemToRemove = form.getValues().items[index]; + + // 기존 ID가 있는 아이템이라면 삭제 목록에 추가 + if (itemToRemove.id !== undefined) { + setDeletedItemIds(prev => [...prev, itemToRemove.id as number]); + } + + remove(index); + setHasUnsavedChanges(true); + + // 포커스 처리: 다음 항목이 있으면 다음 항목으로, 없으면 마지막 항목으로 + setTimeout(() => { + const nextIndex = Math.min(index, fields.length - 1); + if (nextIndex >= 0 && inputRefs.current[nextIndex]) { + inputRefs.current[nextIndex]?.click(); + } + }, 50); + }; + + // 다이얼로그 닫기 전 확인 + const handleDialogClose = (open: boolean) => { + if (!open && hasUnsavedChanges && isEditable) { + setIsExitDialogOpen(true); + } else { + onOpenChange(open); + } + }; + + // 필드 포커스 유틸리티 함수 + const focusField = (selector: string) => { + if (!isEditable) return; + + setTimeout(() => { + const element = document.querySelector(selector) as HTMLInputElement | null; + if (element) { + element.focus(); + } + }, 10); + }; + + // 5) Submit - 업데이트된 제출 로직 (생성/수정 + 삭제 처리) + async function onSubmit(data: ItemsFormSchema) { + if (!isEditable) return; + + try { + setIsSubmitting(true); + + // 각 아이템이 유효한지 확인 + const anyInvalidItems = data.items.some(item => !item.itemCode || item.quantity < 1); + + if (anyInvalidItems) { + toast.error("유효하지 않은 아이템이 있습니다. 모든 필드를 확인해주세요."); + setIsSubmitting(false); + return; + } + + // 1. 삭제 처리 - 삭제된 아이템 ID가 있으면 삭제 요청 + const deletePromises = deletedItemIds.map(id => + deleteRfqItem({ + id: id, + rfqId: rfqId, + rfqType: rfqType ?? RfqType.PURCHASE + }) + ); + + // 2. 생성/수정 처리 - 폼에 남아있는 아이템들 + const upsertPromises = data.items.map((item) => + createRfqItem({ + rfqId: rfqId, + itemCode: item.itemCode, + description: item.description, + // 명시적으로 숫자로 변환 + quantity: Number(item.quantity), + uom: item.uom, + rfqType: rfqType ?? RfqType.PURCHASE, + id: item.id // 기존 ID가 있으면 업데이트, 없으면 생성 + }) + ); + + // 모든 요청 병렬 처리 + await Promise.all([...deletePromises, ...upsertPromises]); + + toast.success("RFQ 아이템이 성공적으로 저장되었습니다!"); + setHasUnsavedChanges(false); + onOpenChange(false); + } catch (err) { + toast.error(`오류가 발생했습니다: ${String(err)}`); + } finally { + setIsSubmitting(false); + } + } + + // 단축키 처리 - 편집 가능한 경우에만 단축키 활성화 + React.useEffect(() => { + if (!isEditable) return; + + const handleKeyDown = (e: KeyboardEvent) => { + // Alt+N: 새 항목 추가 + if (e.altKey && e.key === 'n') { + e.preventDefault(); + handleAddItem(); + } + // Ctrl+S: 저장 + if ((e.ctrlKey || e.metaKey) && e.key === 's') { + e.preventDefault(); + form.handleSubmit(onSubmit)(); + } + // Esc: 포커스된 팝오버 닫기 + if (e.key === 'Escape') { + document.querySelectorAll('[role="combobox"][aria-expanded="true"]').forEach( + (el) => (el as HTMLButtonElement).click() + ); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [form, isEditable]); + + return ( + <> + <Dialog open={open} onOpenChange={handleDialogClose}> + <DialogContent className="max-w-none w-[1200px]"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + {isEditable ? "RFQ 아이템 관리" : "RFQ 아이템 조회"} + <Badge variant="outline" className="ml-2"> + {rfq?.rfqCode || `RFQ #${rfqId}`} + </Badge> + {rfqType && ( + <Badge variant={rfqType === RfqType.PURCHASE ? "default" : "secondary"} className="ml-1"> + {rfqType === RfqType.PURCHASE ? "구매 RFQ" : "예산 RFQ"} + </Badge> + )} + {rfq?.status && ( + <Badge + variant={rfq.status === "DRAFT" ? "outline" : "secondary"} + className="ml-1" + > + {rfq.status} + </Badge> + )} + </DialogTitle> + <DialogDescription> + {isEditable + ? (rfq?.description || '아이템을 각 행에 하나씩 추가할 수 있습니다.') + : '드래프트 상태가 아닌 RFQ는 아이템을 편집할 수 없습니다.'} + </DialogDescription> + </DialogHeader> + <div className="overflow-x-auto w-full"> + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)}> + <div className="space-y-4"> + {/* 헤더 행 (라벨) */} + <div className="flex items-center gap-2 border-b pb-2 font-medium text-sm"> + <div className="w-[250px] pl-3">아이템</div> + <div className="w-[400px] pl-2">설명</div> + <div className="w-[80px] pl-2 text-center">수량</div> + <div className="w-[80px] pl-2 text-center">단위</div> + {isEditable && <div className="w-[42px]"></div>} + </div> + + {/* 아이템 행들 */} + <div className="max-h-[50vh] overflow-y-auto pr-1 space-y-3"> + {fields.map((field, index) => { + // 현재 row의 itemCode + const codeValue = watchItems[index]?.itemCode || ""; + // "이미" 사용된 코드를 모두 구함 + const usedCodes = watchItems + .map((it, i) => i === index ? null : it.itemCode) + .filter(Boolean) as string[]; + + // itemsList에서 "현재 선택한 code"만 예외적으로 허용하고, + // 다른 행에서 이미 사용한 code는 제거 + const filteredItems = (itemsList || []) + .filter((it) => { + if (!it.code) return false; + if (it.code === codeValue) return true; + return !usedCodes.includes(it.code); + }) + .map((it) => ({ + code: it.code ?? "", // fallback + name: it.name, + })); + + // 선택된 아이템 찾기 + const selected = filteredItems.find(it => it.code === codeValue); + + return ( + <div key={field.id} className="flex items-center gap-2 group hover:bg-gray-50 p-1 rounded-md transition-colors"> + {/* -- itemCode + Popover(Select) -- */} + {isEditable ? ( + <FormField + control={form.control} + name={`items.${index}.itemCode`} + render={({ field }) => { + const [popoverOpen, setPopoverOpen] = React.useState(false); + const selected = filteredItems.find(it => it.code === field.value); + + return ( + <FormItem className="flex items-center gap-2 w-[250px]"> + <FormControl> + <Popover open={popoverOpen} onOpenChange={setPopoverOpen}> + <PopoverTrigger asChild> + <Button + // 컴포넌트에 ref 전달 + ref={el => { + inputRefs.current[index] = el; + }} + variant="outline" + role="combobox" + aria-expanded={popoverOpen} + className="w-full justify-between" + data-error={!!form.formState.errors.items?.[index]?.itemCode} + data-state={selected ? "filled" : "empty"} + > + {selected ? `${selected.code} - ${selected.name}` : "아이템 선택..."} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-[400px] p-0"> + <Command> + <CommandInput placeholder="아이템 검색..." className="h-9" autoFocus /> + <CommandList> + <CommandEmpty>아이템을 찾을 수 없습니다.</CommandEmpty> + <CommandGroup> + {filteredItems.map((it) => { + const label = `${it.code} - ${it.name}`; + return ( + <CommandItem + key={it.code} + value={label} + onSelect={() => { + field.onChange(it.code); + setPopoverOpen(false); + // 자동으로 다음 필드로 포커스 이동 + focusField(`input[name="items.${index}.description"]`); + }} + > + {label} + <Check + className={ + "ml-auto h-4 w-4" + + (it.code === field.value ? " opacity-100" : " opacity-0") + } + /> + </CommandItem> + ); + })} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + </FormControl> + {form.formState.errors.items?.[index]?.itemCode && ( + <AlertCircle className="h-4 w-4 text-destructive" /> + )} + </FormItem> + ); + }} + /> + ) : ( + <div className="flex items-center w-[250px] pl-3"> + {selected ? `${selected.code} - ${selected.name}` : codeValue} + </div> + )} + + {/* ID 필드 추가 (숨김) */} + <FormField + control={form.control} + name={`items.${index}.id`} + render={({ field }) => ( + <input type="hidden" {...field} /> + )} + /> + + {/* description */} + {isEditable ? ( + <FormField + control={form.control} + name={`items.${index}.description`} + render={({ field }) => ( + <FormItem className="w-[400px]"> + <FormControl> + <Input + className="w-full" + placeholder="아이템 상세 정보" + {...field} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + focusField(`input[name="items.${index}.quantity"]`); + } + }} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + ) : ( + <div className="w-[400px] pl-2"> + {watchItems[index]?.description || ""} + </div> + )} + + {/* quantity */} + {isEditable ? ( + <FormField + control={form.control} + name={`items.${index}.quantity`} + render={({ field }) => ( + <FormItem className="w-[80px] relative"> + <FormControl> + <Input + type="number" + className="w-full text-center" + min="1" + {...field} + // 값 변경 핸들러 개선 + onChange={(e) => { + const value = e.target.value === '' ? 1 : parseInt(e.target.value, 10); + field.onChange(isNaN(value) ? 1 : value); + }} + // 최소값 보장 (빈 문자열 방지) + onBlur={(e) => { + if (e.target.value === '' || parseInt(e.target.value, 10) < 1) { + field.onChange(1); + } + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + focusField(`input[name="items.${index}.uom"]`); + } + }} + /> + </FormControl> + {form.formState.errors.items?.[index]?.quantity && ( + <AlertCircle className="h-4 w-4 text-destructive absolute right-2 top-2" /> + )} + </FormItem> + )} + /> + ) : ( + <div className="w-[80px] text-center"> + {watchItems[index]?.quantity} + </div> + )} + + {/* uom */} + {isEditable ? ( + <FormField + control={form.control} + name={`items.${index}.uom`} + render={({ field }) => ( + <FormItem className="w-[80px]"> + <FormControl> + <Input + placeholder="each" + className="w-full text-center" + {...field} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + // 마지막 행이면 새로운 행 추가 + if (index === fields.length - 1) { + handleAddItem(); + } else { + // 아니면 다음 행의 아이템 선택으로 이동 + const button = inputRefs.current[index + 1]; + if (button) { + setTimeout(() => button.click(), 10); + } + } + } + }} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + ) : ( + <div className="w-[80px] text-center"> + {watchItems[index]?.uom || "each"} + </div> + )} + + {/* remove row - 편집 모드에서만 표시 */} + {isEditable && ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button + type="button" + variant="ghost" + size="icon" + onClick={() => handleRemoveItem(index)} + className="group-hover:opacity-100 transition-opacity" + aria-label="아이템 삭제" + > + <Trash2 className="h-4 w-4 text-destructive" /> + </Button> + </TooltipTrigger> + <TooltipContent> + <p>아이템 삭제</p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + )} + </div> + ); + })} + </div> + + <div className="flex justify-between items-center pt-2 border-t"> + <div className="flex items-center gap-2"> + {isEditable ? ( + <> + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button type="button" variant="outline" onClick={handleAddItem} className="gap-1"> + <Plus className="h-4 w-4" /> + 아이템 추가 + </Button> + </TooltipTrigger> + <TooltipContent side="bottom"> + <p>단축키: Alt+N</p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + <span className="text-sm text-muted-foreground"> + {fields.length}개 아이템 + </span> + {deletedItemIds.length > 0 && ( + <span className="text-sm text-destructive"> + ({deletedItemIds.length}개 아이템 삭제 예정) + </span> + )} + </> + ) : ( + <span className="text-sm text-muted-foreground"> + {fields.length}개 아이템 + </span> + )} + </div> + + {isEditable && ( + <div className="text-xs text-muted-foreground"> + <span className="inline-flex items-center gap-1 mr-2"> + <kbd className="px-1 py-0.5 bg-gray-100 rounded text-xs">Tab</kbd> + <span>필드 간 이동</span> + </span> + <span className="inline-flex items-center gap-1"> + <kbd className="px-1 py-0.5 bg-gray-100 rounded text-xs">Enter</kbd> + <span>다음 필드로 이동</span> + </span> + </div> + )} + </div> + </div> + + <DialogFooter className="mt-6 gap-2"> + {isEditable ? ( + <> + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button type="button" variant="outline" onClick={() => handleDialogClose(false)}> + <X className="mr-2 h-4 w-4" /> + 취소 + </Button> + </TooltipTrigger> + <TooltipContent>변경사항을 저장하지 않고 나가기</TooltipContent> + </Tooltip> + </TooltipProvider> + + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button + type="submit" + disabled={isSubmitting || (!form.formState.isDirty && deletedItemIds.length === 0) || !form.formState.isValid} + > + {isSubmitting ? ( + <>처리 중...</> + ) : ( + <> + <Save className="mr-2 h-4 w-4" /> + 저장 + </> + )} + </Button> + </TooltipTrigger> + <TooltipContent> + <p>단축키: Ctrl+S</p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + </> + ) : ( + <Button type="button" variant="outline" onClick={() => onOpenChange(false)}> + <X className="mr-2 h-4 w-4" /> + 닫기 + </Button> + )} + </DialogFooter> + </form> + </Form> + </div> + </DialogContent> + </Dialog> + + {/* 저장하지 않고 나가기 확인 다이얼로그 - 편집 모드에서만 활성화 */} + {isEditable && ( + <AlertDialog open={isExitDialogOpen} onOpenChange={setIsExitDialogOpen}> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>저장되지 않은 변경사항</AlertDialogTitle> + <AlertDialogDescription> + 저장되지 않은 변경사항이 있습니다. 그래도 나가시겠습니까? + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel>취소</AlertDialogCancel> + <AlertDialogAction onClick={() => { + setIsExitDialogOpen(false); + onOpenChange(false); + }}> + 저장하지 않고 나가기 + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + )} + </> + ); +}
\ No newline at end of file diff --git a/lib/rfqs/table/add-rfq-dialog.tsx b/lib/rfqs/table/add-rfq-dialog.tsx new file mode 100644 index 00000000..1d824bc0 --- /dev/null +++ b/lib/rfqs/table/add-rfq-dialog.tsx @@ -0,0 +1,349 @@ +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { Check, ChevronsUpDown } from "lucide-react" +import { toast } from "sonner" + +import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover" +import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@/components/ui/command" + +import { useSession } from "next-auth/react" +import { createRfqSchema, type CreateRfqSchema, RfqType } from "../validations" +import { createRfq, getBudgetaryRfqs } from "../service" +import { ProjectSelector } from "@/components/ProjectSelector" +import { type Project } from "../service" +import { cn } from "@/lib/utils" +import { BudgetaryRfqSelector } from "./BudgetaryRfqSelector" +import { type BudgetaryRfq as ServiceBudgetaryRfq } from "../service"; + +// 부모 RFQ 정보 타입 정의 +interface BudgetaryRfq { + id: number; + rfqCode: string; + description: string | null; +} + +interface AddRfqDialogProps { + rfqType?: RfqType; +} + +export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps) { + const [open, setOpen] = React.useState(false) + const { data: session, status } = useSession() + const [budgetaryRfqs, setBudgetaryRfqs] = React.useState<BudgetaryRfq[]>([]) + const [isLoadingBudgetary, setIsLoadingBudgetary] = React.useState(false) + const [budgetarySearchOpen, setBudgetarySearchOpen] = React.useState(false) + const [budgetarySearchTerm, setBudgetarySearchTerm] = React.useState("") + const [selectedBudgetaryRfq, setSelectedBudgetaryRfq] = React.useState<BudgetaryRfq | null>(null) + + // Get the user ID safely, ensuring it's a valid number + const userId = React.useMemo(() => { + const id = session?.user?.id ? Number(session.user.id) : null; + + // Debug logging - remove in production + console.log("Session status:", status); + console.log("Session data:", session); + console.log("User ID:", id); + + return id; + }, [session, status]); + + // RfqType에 따른 타이틀 생성 + const getTitle = () => { + return rfqType === RfqType.PURCHASE + ? "Purchase RFQ" + : "Budgetary RFQ"; + }; + + // RHF + Zod + const form = useForm<CreateRfqSchema>({ + resolver: zodResolver(createRfqSchema), + defaultValues: { + rfqCode: "", + description: "", + projectId: undefined, + parentRfqId: undefined, + dueDate: new Date(), + status: "DRAFT", + rfqType: rfqType, + // Don't set createdBy yet - we'll set it when the form is submitted + createdBy: undefined, + }, + }); + + // Update form values when session loads + React.useEffect(() => { + if (status === "authenticated" && userId) { + form.setValue("createdBy", userId); + } + }, [status, userId, form]); + + // Budgetary RFQ 목록 로드 (Purchase RFQ 생성 시만) + React.useEffect(() => { + if (rfqType === RfqType.PURCHASE && open) { + const loadBudgetaryRfqs = async () => { + setIsLoadingBudgetary(true); + try { + const result = await getBudgetaryRfqs(); + if ('rfqs' in result) { + setBudgetaryRfqs(result.rfqs as unknown as BudgetaryRfq[]); + } else if ('error' in result) { + console.error("Budgetary RFQs 로드 오류:", result.error); + } + } catch (error) { + console.error("Budgetary RFQs 로드 오류:", error); + } finally { + setIsLoadingBudgetary(false); + } + }; + + loadBudgetaryRfqs(); + } + }, [rfqType, open]); + + // 검색어로 필터링된 Budgetary RFQ 목록 + const filteredBudgetaryRfqs = React.useMemo(() => { + if (!budgetarySearchTerm.trim()) return budgetaryRfqs; + + const lowerSearch = budgetarySearchTerm.toLowerCase(); + return budgetaryRfqs.filter( + rfq => + rfq.rfqCode.toLowerCase().includes(lowerSearch) || + (rfq.description && rfq.description.toLowerCase().includes(lowerSearch)) + ); + }, [budgetaryRfqs, budgetarySearchTerm]); + + // 프로젝트 선택 처리 + const handleProjectSelect = (project: Project) => { + form.setValue("projectId", project.id); + }; + + // Budgetary RFQ 선택 처리 + const handleBudgetaryRfqSelect = (rfq: BudgetaryRfq) => { + setSelectedBudgetaryRfq(rfq); + form.setValue("parentRfqId", rfq.id); + setBudgetarySearchOpen(false); + }; + + async function onSubmit(data: CreateRfqSchema) { + // Check if user is authenticated before submitting + if (status !== "authenticated" || !userId) { + toast.error("사용자 인증이 필요합니다. 다시 로그인해주세요."); + return; + } + + // Make sure createdBy is set with the current user ID + const submitData = { + ...data, + createdBy: userId + }; + + console.log("Submitting form data:", submitData); + + const result = await createRfq(submitData); + if (result.error) { + toast.error(`에러: ${result.error}`); + return; + } + + toast.success("RFQ가 성공적으로 생성되었습니다."); + form.reset(); + setSelectedBudgetaryRfq(null); + setOpen(false); + } + + function handleDialogOpenChange(nextOpen: boolean) { + if (!nextOpen) { + form.reset(); + setSelectedBudgetaryRfq(null); + } + setOpen(nextOpen); + } + + // Return a message or disabled state if user is not authenticated + if (status === "loading") { + return <Button variant="outline" size="sm" disabled>Loading...</Button>; + } + + return ( + <Dialog open={open} onOpenChange={handleDialogOpenChange}> + {/* 모달을 열기 위한 버튼 */} + <DialogTrigger asChild> + <Button variant="default" size="sm"> + Add {getTitle()} + </Button> + </DialogTrigger> + + <DialogContent> + <DialogHeader> + <DialogTitle>Create New {getTitle()}</DialogTitle> + <DialogDescription> + 새 {getTitle()} 정보를 입력하고 <b>Create</b> 버튼을 누르세요. + </DialogDescription> + </DialogHeader> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)}> + <div className="space-y-4 py-4"> + {/* rfqType - hidden field */} + <FormField + control={form.control} + name="rfqType" + render={({ field }) => ( + <input type="hidden" {...field} /> + )} + /> + + {/* Project Selector */} + <FormField + control={form.control} + name="projectId" + render={({ field }) => ( + <FormItem> + <FormLabel>Project</FormLabel> + <FormControl> + <ProjectSelector + selectedProjectId={field.value} + onProjectSelect={handleProjectSelect} + placeholder="프로젝트 선택..." + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Budgetary RFQ Selector - 구매용 RFQ 생성 시에만 표시 */} + {rfqType === RfqType.PURCHASE && ( + <FormField + control={form.control} + name="parentRfqId" + render={({ field }) => ( + <FormItem> + <FormLabel>Budgetary RFQ (Optional)</FormLabel> + <FormControl> + <BudgetaryRfqSelector + selectedRfqId={field.value as number | undefined} + onRfqSelect={(rfq) => { + setSelectedBudgetaryRfq(rfq as any); + form.setValue("parentRfqId", rfq?.id); + }} + placeholder="Budgetary RFQ 선택..." + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + )} + + {/* rfqCode */} + <FormField + control={form.control} + name="rfqCode" + render={({ field }) => ( + <FormItem> + <FormLabel>RFQ Code</FormLabel> + <FormControl> + <Input placeholder="e.g. RFQ-2025-001" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* description */} + <FormField + control={form.control} + name="description" + render={({ field }) => ( + <FormItem> + <FormLabel>RFQ Description</FormLabel> + <FormControl> + <Input placeholder="e.g. 설명을 입력하세요" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* dueDate */} + <FormField + control={form.control} + name="dueDate" + render={({ field }) => ( + <FormItem> + <FormLabel>Due Date</FormLabel> + <FormControl> + <Input + type="date" + value={field.value ? field.value.toISOString().slice(0, 10) : ""} + onChange={(e) => { + const val = e.target.value + if (val) { + field.onChange(new Date(val + "T00:00:00")) + } + }} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* status (Read-only) */} + <FormField + control={form.control} + name="status" + render={({ field }) => ( + <FormItem> + <FormLabel>Status</FormLabel> + <FormControl> + <Input + disabled + className="capitalize" + {...field} + onChange={() => {}} // Prevent changes + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => setOpen(false)} + > + Cancel + </Button> + <Button + type="submit" + disabled={form.formState.isSubmitting || status !== "authenticated"} + > + Create + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/rfqs/table/attachment-rfq-sheet.tsx b/lib/rfqs/table/attachment-rfq-sheet.tsx new file mode 100644 index 00000000..57a170e1 --- /dev/null +++ b/lib/rfqs/table/attachment-rfq-sheet.tsx @@ -0,0 +1,430 @@ +"use client" + +import * as React from "react" +import { z } from "zod" +import { useForm, useFieldArray } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" + +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetDescription, + SheetFooter, + SheetClose, +} from "@/components/ui/sheet" +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + FormDescription +} from "@/components/ui/form" +import { Trash2, Plus, Loader, Download, X, Eye, AlertCircle } from "lucide-react" +import { useToast } from "@/hooks/use-toast" +import { Badge } from "@/components/ui/badge" + +import { + Dropzone, + DropzoneDescription, + DropzoneInput, + DropzoneTitle, + DropzoneUploadIcon, + DropzoneZone, +} from "@/components/ui/dropzone" +import { + FileList, + FileListAction, + FileListDescription, + FileListHeader, + FileListIcon, + FileListInfo, + FileListItem, + FileListName, + FileListSize, +} from "@/components/ui/file-list" + +import prettyBytes from "pretty-bytes" +import { processRfqAttachments } from "../service" +import { format } from "path" +import { formatDate } from "@/lib/utils" +import { RfqType } from "../validations" +import { RfqWithItemCount } from "@/db/schema/rfq" + +const MAX_FILE_SIZE = 6e8 // 600MB + +/** 기존 첨부 파일 정보 */ +interface ExistingAttachment { + id: number + fileName: string + filePath: string + createdAt?: Date // or Date + vendorId?: number | null + size?: number +} + +/** 새로 업로드할 파일 */ +const newUploadSchema = z.object({ + fileObj: z.any().optional(), // 실제 File +}) + +/** 기존 첨부 (react-hook-form에서 관리) */ +const existingAttachSchema = z.object({ + id: z.number(), + fileName: z.string(), + filePath: z.string(), + vendorId: z.number().nullable().optional(), + createdAt: z.custom<Date>().optional(), // or use z.any().optional() + size: z.number().optional(), +}) + +/** RHF 폼 전체 스키마 */ +const attachmentsFormSchema = z.object({ + rfqId: z.number().int(), + existing: z.array(existingAttachSchema), + newUploads: z.array(newUploadSchema), +}) + +type AttachmentsFormValues = z.infer<typeof attachmentsFormSchema> + +interface RfqAttachmentsSheetProps + extends React.ComponentPropsWithRef<typeof Sheet> { + defaultAttachments?: ExistingAttachment[] + rfqType?: RfqType + rfq: RfqWithItemCount | null + /** 업로드/삭제 후 상위 테이블에 itemCount 등을 업데이트하기 위한 콜백 */ + onAttachmentsUpdated?: (rfqId: number, newItemCount: number) => void +} + +/** + * RfqAttachmentsSheet: + * - 기존 첨부 목록 (다운로드 + 삭제) + * - 새 파일 Dropzone + * - Save 시 processRfqAttachments(server action) + */ +export function RfqAttachmentsSheet({ + defaultAttachments = [], + onAttachmentsUpdated, + rfq, + rfqType, + ...props +}: RfqAttachmentsSheetProps) { + const { toast } = useToast() + const [isPending, startUpdate] = React.useTransition() + const rfqId = rfq?.rfqId ?? 0; + + // 편집 가능 여부 확인 - DRAFT 상태일 때만 편집 가능 + const isEditable = rfq?.status === "DRAFT"; + + // React Hook Form + const form = useForm<AttachmentsFormValues>({ + resolver: zodResolver(attachmentsFormSchema), + defaultValues: { + rfqId, + existing: [], + newUploads: [], + }, + }) + + const { reset, control, handleSubmit } = form + + // defaultAttachments가 바뀔 때마다, RHF 상태를 reset + React.useEffect(() => { + reset({ + rfqId, + existing: defaultAttachments.map((att) => ({ + ...att, + vendorId: att.vendorId ?? null, + size: att.size ?? undefined, + })), + newUploads: [], + }) + }, [rfqId, defaultAttachments, reset]) + + // Field Arrays + const { + fields: existingFields, + remove: removeExisting, + } = useFieldArray({ control, name: "existing" }) + + const { + fields: newUploadFields, + append: appendNewUpload, + remove: removeNewUpload, + } = useFieldArray({ control, name: "newUploads" }) + + // 기존 첨부 항목 중 삭제된 것 찾기 + function findRemovedExistingIds(data: AttachmentsFormValues): number[] { + const finalIds = data.existing.map((att) => att.id) + const originalIds = defaultAttachments.map((att) => att.id) + return originalIds.filter((id) => !finalIds.includes(id)) + } + + async function onSubmit(data: AttachmentsFormValues) { + // 편집 불가능한 상태에서는 제출 방지 + if (!isEditable) return; + + startUpdate(async () => { + try { + const removedExistingIds = findRemovedExistingIds(data) + const newFiles = data.newUploads + .map((it) => it.fileObj) + .filter((f): f is File => !!f) + + // 서버 액션 + const res = await processRfqAttachments({ + rfqId, + removedExistingIds, + newFiles, + vendorId: null, // vendor ID if needed + rfqType + }) + + if (!res.ok) throw new Error(res.error ?? "Unknown error") + + const newCount = res.updatedItemCount ?? 0 + + toast({ + variant: "default", + title: "Success", + description: "File(s) updated", + }) + + // 상위 테이블 등에 itemCount 업데이트 + onAttachmentsUpdated?.(rfqId, newCount) + + // 모달 닫기 + props.onOpenChange?.(false) + } catch (err) { + toast({ + variant: "destructive", + title: "Error", + description: String(err), + }) + } + }) + } + + /** 기존 첨부 - X 버튼 */ + function handleRemoveExisting(idx: number) { + // 편집 불가능한 상태에서는 삭제 방지 + if (!isEditable) return; + removeExisting(idx) + } + + /** 드롭존에서 파일 받기 */ + function handleDropAccepted(acceptedFiles: File[]) { + // 편집 불가능한 상태에서는 파일 추가 방지 + if (!isEditable) return; + const mapped = acceptedFiles.map((file) => ({ fileObj: file })) + appendNewUpload(mapped) + } + + /** 드롭존에서 파일 거부(에러) */ + function handleDropRejected(fileRejections: any[]) { + // 편집 불가능한 상태에서는 무시 + if (!isEditable) return; + + fileRejections.forEach((rej) => { + toast({ + variant: "destructive", + title: "File Error", + description: rej.file.name + " not accepted", + }) + }) + } + + return ( + <Sheet {...props}> + <SheetContent className="flex flex-col gap-6 sm:max-w-sm"> + <SheetHeader> + <SheetTitle className="flex items-center gap-2"> + {isEditable ? "Manage Attachments" : "View Attachments"} + {rfq?.status && ( + <Badge + variant={rfq.status === "DRAFT" ? "outline" : "secondary"} + className="ml-1" + > + {rfq.status} + </Badge> + )} + </SheetTitle> + <SheetDescription> + {`RFQ ${rfq?.rfqCode} - `} + {isEditable ? '파일 첨부/삭제' : '첨부 파일 보기'} + {!isEditable && ( + <div className="mt-1 text-xs flex items-center gap-1 text-amber-600"> + <AlertCircle className="h-3 w-3" /> + <span>드래프트 상태가 아닌 RFQ는 첨부파일을 수정할 수 없습니다.</span> + </div> + )} + </SheetDescription> + </SheetHeader> + + <Form {...form}> + <form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4"> + {/* 1) 기존 첨부 목록 */} + <div className="space-y-2"> + <p className="font-semibold text-sm">Existing Attachments</p> + {existingFields.length === 0 && ( + <p className="text-sm text-muted-foreground">No existing attachments</p> + )} + {existingFields.map((field, index) => { + const vendorLabel = field.vendorId ? "(Vendor)" : "(Internal)" + return ( + <div + key={field.id} + className="flex items-center justify-between rounded border p-2" + > + <div className="flex flex-col text-sm"> + <span className="font-medium"> + {field.fileName} {vendorLabel} + </span> + {field.size && ( + <span className="text-xs text-muted-foreground"> + {Math.round(field.size / 1024)} KB + </span> + )} + {field.createdAt && ( + <span className="text-xs text-muted-foreground"> + Created at {formatDate(field.createdAt)} + </span> + )} + </div> + <div className="flex items-center gap-2"> + {/* 1) Download button (if filePath) */} + {field.filePath && ( + <a + href={`/api/rfq-download?path=${encodeURIComponent(field.filePath)}`} + download={field.fileName} + className="text-sm" + > + <Button variant="ghost" size="icon" type="button"> + <Download className="h-4 w-4" /> + </Button> + </a> + )} + {/* 2) Remove button - 편집 가능할 때만 표시 */} + {isEditable && ( + <Button + type="button" + variant="ghost" + size="icon" + onClick={() => handleRemoveExisting(index)} + > + <X className="h-4 w-4" /> + </Button> + )} + </div> + </div> + ) + })} + </div> + + {/* 2) Dropzone for new uploads - 편집 가능할 때만 표시 */} + {isEditable ? ( + <> + <Dropzone + maxSize={MAX_FILE_SIZE} + onDropAccepted={handleDropAccepted} + onDropRejected={handleDropRejected} + > + {({ maxSize }) => ( + <FormField + control={control} + name="newUploads" // not actually used for storing each file detail + render={() => ( + <FormItem> + <FormLabel>Drop Files Here</FormLabel> + <DropzoneZone className="flex justify-center"> + <FormControl> + <DropzoneInput /> + </FormControl> + <div className="flex items-center gap-6"> + <DropzoneUploadIcon /> + <div className="grid gap-0.5"> + <DropzoneTitle>Drop to upload</DropzoneTitle> + <DropzoneDescription> + Max size: {maxSize ? prettyBytes(maxSize) : "??? MB"} + </DropzoneDescription> + </div> + </div> + </DropzoneZone> + <FormDescription>Alternatively, click browse.</FormDescription> + <FormMessage /> + </FormItem> + )} + /> + )} + </Dropzone> + + {/* newUpload fields -> FileList */} + {newUploadFields.length > 0 && ( + <div className="grid gap-4"> + <h6 className="font-semibold leading-none tracking-tight"> + {`Files (${newUploadFields.length})`} + </h6> + <FileList> + {newUploadFields.map((field, idx) => { + const fileObj = form.getValues(`newUploads.${idx}.fileObj`) + if (!fileObj) return null + + const fileName = fileObj.name + const fileSize = fileObj.size + return ( + <FileListItem key={field.id}> + <FileListHeader> + <FileListIcon /> + <FileListInfo> + <FileListName>{fileName}</FileListName> + <FileListDescription> + {`${prettyBytes(fileSize)}`} + </FileListDescription> + </FileListInfo> + <FileListAction onClick={() => removeNewUpload(idx)}> + <X /> + <span className="sr-only">Remove</span> + </FileListAction> + </FileListHeader> + </FileListItem> + ) + })} + </FileList> + </div> + )} + </> + ) : ( + <div className="p-3 bg-muted rounded-md flex items-center justify-center"> + <div className="text-center text-sm text-muted-foreground"> + <Eye className="h-4 w-4 mx-auto mb-2" /> + <p>보기 모드에서는 파일 첨부를 할 수 없습니다.</p> + </div> + </div> + )} + + <SheetFooter className="gap-2 pt-2 sm:space-x-0"> + <SheetClose asChild> + <Button type="button" variant="outline"> + {isEditable ? "Cancel" : "Close"} + </Button> + </SheetClose> + {isEditable && ( + <Button + type="submit" + disabled={isPending || (form.getValues().newUploads.length === 0 && defaultAttachments.length === form.getValues().existing.length)} + > + {isPending && <Loader className="mr-2 h-4 w-4 animate-spin" />} + Save + </Button> + )} + </SheetFooter> + </form> + </Form> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/lib/rfqs/table/delete-rfqs-dialog.tsx b/lib/rfqs/table/delete-rfqs-dialog.tsx new file mode 100644 index 00000000..09596bc7 --- /dev/null +++ b/lib/rfqs/table/delete-rfqs-dialog.tsx @@ -0,0 +1,149 @@ +"use client" + +import * as React from "react" +import { type Row } from "@tanstack/react-table" +import { Loader, Trash } from "lucide-react" +import { toast } from "sonner" + +import { useMediaQuery } from "@/hooks/use-media-query" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer" + +import { Rfq, RfqWithItemCount } from "@/db/schema/rfq" +import { removeRfqs } from "../service" + +interface DeleteRfqsDialogProps + extends React.ComponentPropsWithoutRef<typeof Dialog> { + rfqs: Row<RfqWithItemCount>["original"][] + showTrigger?: boolean + onSuccess?: () => void +} + +export function DeleteRfqsDialog({ + rfqs, + showTrigger = true, + onSuccess, + ...props +}: DeleteRfqsDialogProps) { + const [isDeletePending, startDeleteTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + + function onDelete() { + startDeleteTransition(async () => { + const { error } = await removeRfqs({ + ids: rfqs.map((rfq) => rfq.rfqId), + }) + + if (error) { + toast.error(error) + return + } + + props.onOpenChange?.(false) + toast.success("Tasks deleted") + onSuccess?.() + }) + } + + if (isDesktop) { + return ( + <Dialog {...props}> + {showTrigger ? ( + <DialogTrigger asChild> + <Button variant="outline" size="sm"> + <Trash className="mr-2 size-4" aria-hidden="true" /> + Delete ({rfqs.length}) + </Button> + </DialogTrigger> + ) : null} + <DialogContent> + <DialogHeader> + <DialogTitle>Are you absolutely sure?</DialogTitle> + <DialogDescription> + This action cannot be undone. This will permanently delete your{" "} + <span className="font-medium">{rfqs.length}</span> + {rfqs.length === 1 ? " task" : " rfqs"} from our servers. + </DialogDescription> + </DialogHeader> + <DialogFooter className="gap-2 sm:space-x-0"> + <DialogClose asChild> + <Button variant="outline">Cancel</Button> + </DialogClose> + <Button + aria-label="Delete selected rows" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending} + > + {isDeletePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + Delete + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) + } + + return ( + <Drawer {...props}> + {showTrigger ? ( + <DrawerTrigger asChild> + <Button variant="outline" size="sm"> + <Trash className="mr-2 size-4" aria-hidden="true" /> + Delete ({rfqs.length}) + </Button> + </DrawerTrigger> + ) : null} + <DrawerContent> + <DrawerHeader> + <DrawerTitle>Are you absolutely sure?</DrawerTitle> + <DrawerDescription> + This action cannot be undone. This will permanently delete your{" "} + <span className="font-medium">{rfqs.length}</span> + {rfqs.length === 1 ? " task" : " rfqs"} from our servers. + </DrawerDescription> + </DrawerHeader> + <DrawerFooter className="gap-2 sm:space-x-0"> + <DrawerClose asChild> + <Button variant="outline">Cancel</Button> + </DrawerClose> + <Button + aria-label="Delete selected rows" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending} + > + {isDeletePending && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + Delete + </Button> + </DrawerFooter> + </DrawerContent> + </Drawer> + ) +} diff --git a/lib/rfqs/table/feature-flags-provider.tsx b/lib/rfqs/table/feature-flags-provider.tsx new file mode 100644 index 00000000..81131894 --- /dev/null +++ b/lib/rfqs/table/feature-flags-provider.tsx @@ -0,0 +1,108 @@ +"use client" + +import * as React from "react" +import { useQueryState } from "nuqs" + +import { dataTableConfig, type DataTableConfig } from "@/config/data-table" +import { cn } from "@/lib/utils" +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" + +type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"] + +interface FeatureFlagsContextProps { + featureFlags: FeatureFlagValue[] + setFeatureFlags: (value: FeatureFlagValue[]) => void +} + +const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({ + featureFlags: [], + setFeatureFlags: () => {}, +}) + +export function useFeatureFlags() { + const context = React.useContext(FeatureFlagsContext) + if (!context) { + throw new Error( + "useFeatureFlags must be used within a FeatureFlagsProvider" + ) + } + return context +} + +interface FeatureFlagsProviderProps { + children: React.ReactNode +} + +export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) { + const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>( + "flags", + { + defaultValue: [], + parse: (value) => value.split(",") as FeatureFlagValue[], + serialize: (value) => value.join(","), + eq: (a, b) => + a.length === b.length && a.every((value, index) => value === b[index]), + clearOnDefault: true, + shallow: false, + } + ) + + return ( + <FeatureFlagsContext.Provider + value={{ + featureFlags, + setFeatureFlags: (value) => void setFeatureFlags(value), + }} + > + <div className="w-full overflow-x-auto"> + <ToggleGroup + type="multiple" + variant="outline" + size="sm" + value={featureFlags} + onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)} + className="w-fit gap-0" + > + {dataTableConfig.featureFlags.map((flag, index) => ( + <Tooltip key={flag.value}> + <ToggleGroupItem + value={flag.value} + className={cn( + "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90", + { + "rounded-l-sm border-r-0": index === 0, + "rounded-r-sm": + index === dataTableConfig.featureFlags.length - 1, + } + )} + asChild + > + <TooltipTrigger> + <flag.icon className="size-3.5 shrink-0" aria-hidden="true" /> + {flag.label} + </TooltipTrigger> + </ToggleGroupItem> + <TooltipContent + align="start" + side="bottom" + sideOffset={6} + className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground" + > + <div>{flag.tooltipTitle}</div> + <div className="text-xs text-muted-foreground"> + {flag.tooltipDescription} + </div> + </TooltipContent> + </Tooltip> + ))} + </ToggleGroup> + </div> + {children} + </FeatureFlagsContext.Provider> + ) +} diff --git a/lib/rfqs/table/feature-flags.tsx b/lib/rfqs/table/feature-flags.tsx new file mode 100644 index 00000000..aaae6af2 --- /dev/null +++ b/lib/rfqs/table/feature-flags.tsx @@ -0,0 +1,96 @@ +"use client" + +import * as React from "react" +import { useQueryState } from "nuqs" + +import { dataTableConfig, type DataTableConfig } from "@/config/data-table" +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" + +type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"] + +interface TasksTableContextProps { + featureFlags: FeatureFlagValue[] + setFeatureFlags: (value: FeatureFlagValue[]) => void +} + +const TasksTableContext = React.createContext<TasksTableContextProps>({ + featureFlags: [], + setFeatureFlags: () => {}, +}) + +export function useTasksTable() { + const context = React.useContext(TasksTableContext) + if (!context) { + throw new Error("useTasksTable must be used within a TasksTableProvider") + } + return context +} + +export function TasksTableProvider({ children }: React.PropsWithChildren) { + const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>( + "featureFlags", + { + defaultValue: [], + parse: (value) => value.split(",") as FeatureFlagValue[], + serialize: (value) => value.join(","), + eq: (a, b) => + a.length === b.length && a.every((value, index) => value === b[index]), + clearOnDefault: true, + } + ) + + return ( + <TasksTableContext.Provider + value={{ + featureFlags, + setFeatureFlags: (value) => void setFeatureFlags(value), + }} + > + <div className="w-full overflow-x-auto"> + <ToggleGroup + type="multiple" + variant="outline" + size="sm" + value={featureFlags} + onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)} + className="w-fit" + > + {dataTableConfig.featureFlags.map((flag) => ( + <Tooltip key={flag.value}> + <ToggleGroupItem + value={flag.value} + className="whitespace-nowrap px-3 text-xs" + asChild + > + <TooltipTrigger> + <flag.icon + className="mr-2 size-3.5 shrink-0" + aria-hidden="true" + /> + {flag.label} + </TooltipTrigger> + </ToggleGroupItem> + <TooltipContent + align="start" + side="bottom" + sideOffset={6} + className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground" + > + <div>{flag.tooltipTitle}</div> + <div className="text-xs text-muted-foreground"> + {flag.tooltipDescription} + </div> + </TooltipContent> + </Tooltip> + ))} + </ToggleGroup> + </div> + {children} + </TasksTableContext.Provider> + ) +} diff --git a/lib/rfqs/table/rfqs-table-columns.tsx b/lib/rfqs/table/rfqs-table-columns.tsx new file mode 100644 index 00000000..98df3bc8 --- /dev/null +++ b/lib/rfqs/table/rfqs-table-columns.tsx @@ -0,0 +1,315 @@ +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { Ellipsis, Paperclip, Package } from "lucide-react" +import { toast } from "sonner" + +import { formatDate } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" + +import { getRFQStatusIcon } from "@/lib/tasks/utils" +import { rfqsColumnsConfig } from "@/config/rfqsColumnsConfig" +import { RfqWithItemCount } from "@/db/schema/rfq" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { useRouter } from "next/navigation" +import { RfqType } from "../validations" + +type NextRouter = ReturnType<typeof useRouter>; + +interface GetColumnsProps { + setRowAction: React.Dispatch< + React.SetStateAction<DataTableRowAction<RfqWithItemCount> | null> + > + openItemsModal: (rfqId: number) => void + openAttachmentsSheet: (rfqId: number) => void + router: NextRouter + rfqType?: RfqType +} + +/** + * tanstack table 컬럼 정의 (중첩 헤더 버전) + */ +export function getColumns({ + setRowAction, + openItemsModal, + openAttachmentsSheet, + router, + rfqType, +}: GetColumnsProps): ColumnDef<RfqWithItemCount>[] { + // ---------------------------------------------------------------- + // 1) select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef<RfqWithItemCount> = { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + } + + // ---------------------------------------------------------------- + // 2) actions 컬럼 (Dropdown 메뉴) + // ---------------------------------------------------------------- + const actionsColumn: ColumnDef<RfqWithItemCount> = { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + + // Proceed 버튼 클릭 시 호출되는 함수 + const handleProceed = () => { + const rfq = row.original + const itemCount = Number(rfq.itemCount || 0) + const attachCount = Number(rfq.attachCount || 0) + + // 아이템과 첨부파일이 모두 0보다 커야 진행 가능 + if (itemCount > 0 && attachCount > 0) { + router.push( + rfqType === RfqType.PURCHASE + ? `/evcp/rfq/${rfq.rfqId}` + : `/evcp/budgetary/${rfq.rfqId}` + ) + } else { + // 조건을 충족하지 않는 경우 토스트 알림 표시 + if (itemCount === 0 && attachCount === 0) { + toast.error("아이템과 첨부파일을 먼저 추가해주세요.") + } else if (itemCount === 0) { + toast.error("아이템을 먼저 추가해주세요.") + } else { + toast.error("첨부파일을 먼저 추가해주세요.") + } + } + } + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-8 p-0 data-[state=open]:bg-muted" + > + <Ellipsis className="size-4" aria-hidden="true" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-40"> + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "update" })} + > + Edit + </DropdownMenuItem> + <DropdownMenuSeparator /> + <DropdownMenuItem onSelect={handleProceed}> + {row.original.status ==="DRAFT"?"Proceed":"View Detail"} + <DropdownMenuShortcut>↵</DropdownMenuShortcut> + </DropdownMenuItem> + <DropdownMenuSeparator /> + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "delete" })} + > + Delete + <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ) + }, + size: 40, + } + + // ---------------------------------------------------------------- + // 3) itemsColumn (아이템 개수 표시: 아이콘 + Badge) + // ---------------------------------------------------------------- + const itemsColumn: ColumnDef<RfqWithItemCount> = { + id: "items", + header: "Items", + cell: ({ row }) => { + const rfq = row.original + const itemCount = rfq.itemCount || 0 + + const handleClick = () => { + openItemsModal(rfq.rfqId) + } + + return ( + <Button + variant="ghost" + size="sm" + className="relative h-8 w-8 p-0 group" + onClick={handleClick} + aria-label={ + itemCount > 0 ? `View ${itemCount} items` : "Add items" + } + > + <Package className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> + {itemCount > 0 && ( + <Badge + variant="secondary" + className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center" + > + {itemCount} + </Badge> + )} + <span className="sr-only"> + {itemCount > 0 ? `${itemCount} Items` : "Add Items"} + </span> + </Button> + ) + }, + enableSorting: false, + size: 60, + } + + // ---------------------------------------------------------------- + // 4) attachmentsColumn (첨부파일 개수 표시: 아이콘 + Badge) + // ---------------------------------------------------------------- + const attachmentsColumn: ColumnDef<RfqWithItemCount> = { + id: "attachments", + header: "Attachments", + cell: ({ row }) => { + const fileCount = row.original.attachCount ?? 0 + + const handleClick = () => { + openAttachmentsSheet(row.original.rfqId) + } + + return ( + <Button + variant="ghost" + size="sm" + className="relative h-8 w-8 p-0 group" + onClick={handleClick} + aria-label={ + fileCount > 0 ? `View ${fileCount} files` : "Add files" + } + > + <Paperclip className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> + {fileCount > 0 && ( + <Badge + variant="secondary" + className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center" + > + {fileCount} + </Badge> + )} + <span className="sr-only"> + {fileCount > 0 ? `${fileCount} Files` : "Add Files"} + </span> + </Button> + ) + }, + enableSorting: false, + size: 60, + } + + // ---------------------------------------------------------------- + // 5) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 + // ---------------------------------------------------------------- + const groupMap: Record<string, ColumnDef<RfqWithItemCount>[]> = {} + + rfqsColumnsConfig.forEach((cfg) => { + const groupName = cfg.group || "_noGroup" + + if (!groupMap[groupName]) { + groupMap[groupName] = [] + } + + // child column 정의 + const childCol: ColumnDef<RfqWithItemCount> = { + accessorKey: cfg.id, + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title={cfg.label} /> + ), + meta: { + excelHeader: cfg.excelHeader, + group: cfg.group, + type: cfg.type, + }, + cell: ({ row, cell }) => { + if (cfg.id === "status") { + const statusVal = row.original.status + if (!statusVal) return null + const Icon = getRFQStatusIcon( + statusVal as "DRAFT" | "PUBLISHED" | "EVALUATION" | "AWARDED" + ) + return ( + <div className="flex w-[6.25rem] items-center"> + <Icon className="mr-2 size-4 text-muted-foreground" aria-hidden="true" /> + <span className="capitalize">{statusVal}</span> + </div> + ) + } + + if (cfg.id === "createdAt" || cfg.id === "updatedAt") { + const dateVal = cell.getValue() as Date + return formatDate(dateVal) + } + + return row.getValue(cfg.id) ?? "" + }, + } + + groupMap[groupName].push(childCol) + }) + + // groupMap -> nestedColumns + const nestedColumns: ColumnDef<RfqWithItemCount>[] = [] + Object.entries(groupMap).forEach(([groupName, colDefs]) => { + if (groupName === "_noGroup") { + nestedColumns.push(...colDefs) + } else { + nestedColumns.push({ + id: groupName, + header: groupName, + columns: colDefs, + }) + } + }) + + // ---------------------------------------------------------------- + // 6) 최종 컬럼 배열 + // ---------------------------------------------------------------- + return [ + selectColumn, + ...nestedColumns, + attachmentsColumn, // 첨부파일 + actionsColumn, + itemsColumn, // 아이템 + ] +}
\ No newline at end of file diff --git a/lib/rfqs/table/rfqs-table-floating-bar.tsx b/lib/rfqs/table/rfqs-table-floating-bar.tsx new file mode 100644 index 00000000..daef7e0b --- /dev/null +++ b/lib/rfqs/table/rfqs-table-floating-bar.tsx @@ -0,0 +1,338 @@ +"use client" + +import * as React from "react" +import { Table } from "@tanstack/react-table" +import { toast } from "sonner" +import { Calendar, type CalendarProps } from "@/components/ui/calendar" +import { Button } from "@/components/ui/button" +import { Portal } from "@/components/ui/portal" +import { + Select, + SelectTrigger, + SelectContent, + SelectGroup, + SelectItem, + SelectValue, +} from "@/components/ui/select" +import { Separator } from "@/components/ui/separator" +import { + Tooltip, + TooltipTrigger, + TooltipContent, +} from "@/components/ui/tooltip" +import { Kbd } from "@/components/kbd" +import { ActionConfirmDialog } from "@/components/ui/action-dialog" + +import { ArrowUp, CheckCircle2, Download, Loader, Trash2, X, CalendarIcon } from "lucide-react" + +import { exportTableToExcel } from "@/lib/export" + +import { RfqWithItemCount, rfqs } from "@/db/schema/rfq" +import { modifyRfqs, removeRfqs } from "../service" + +interface RfqsTableFloatingBarProps { + table: Table<RfqWithItemCount> +} + +/** + * 추가된 로직: + * - 달력(캘린더) 아이콘 버튼 + * - 눌렀을 때 Popover로 Calendar 표시 + * - 날짜 선택 시 Confirm 다이얼로그 → modifyRfqs({ dueDate }) + */ +export function RfqsTableFloatingBar({ table }: RfqsTableFloatingBarProps) { + const rows = table.getFilteredSelectedRowModel().rows + const [isPending, startTransition] = React.useTransition() + const [action, setAction] = React.useState<"update-status" | "export" | "delete" | "update-dueDate">() + const [confirmDialogOpen, setConfirmDialogOpen] = React.useState(false) + + const [confirmProps, setConfirmProps] = React.useState<{ + title: string + description?: string + onConfirm: () => Promise<void> | void + }>({ + title: "", + description: "", + onConfirm: () => {}, + }) + + // 캘린더 Popover 열림 여부 + const [calendarOpen, setCalendarOpen] = React.useState(false) + const [selectedDate, setSelectedDate] = React.useState<Date | null>(null) + + // Clear selection on Escape key press + React.useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + table.toggleAllRowsSelected(false) + } + } + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [table]) + + function handleDeleteConfirm() { + setAction("delete") + setConfirmProps({ + title: `Delete ${rows.length} RFQ${rows.length > 1 ? "s" : ""}?`, + description: "This action cannot be undone.", + onConfirm: async () => { + startTransition(async () => { + const { error } = await removeRfqs({ + ids: rows.map((row) => row.original.rfqId), + }) + if (error) { + toast.error(error) + return + } + toast.success("RFQs deleted") + table.toggleAllRowsSelected(false) + setConfirmDialogOpen(false) + }) + }, + }) + setConfirmDialogOpen(true) + } + + function handleSelectStatus(newStatus: RfqWithItemCount["status"]) { + setAction("update-status") + setConfirmProps({ + title: `Update ${rows.length} RFQ${rows.length > 1 ? "s" : ""} with status: ${newStatus}?`, + description: "This action will override their current status.", + onConfirm: async () => { + startTransition(async () => { + const { error } = await modifyRfqs({ + ids: rows.map((row) => row.original.rfqId), + status: newStatus as "DRAFT" | "PUBLISHED" | "EVALUATION" | "AWARDED", + }) + if (error) { + toast.error(error) + return + } + toast.success("RFQs updated") + setConfirmDialogOpen(false) + }) + }, + }) + setConfirmDialogOpen(true) + } + + // 1) 달력에서 날짜를 선택했을 때 → Confirm 다이얼로그 + function handleDueDateSelect(newDate: Date) { + setAction("update-dueDate") + + setConfirmProps({ + title: `Update ${rows.length} RFQ${rows.length > 1 ? "s" : ""} Due Date to ${newDate.toDateString()}?`, + description: "This action will override their current due date.", + onConfirm: async () => { + startTransition(async () => { + const { error } = await modifyRfqs({ + ids: rows.map((r) => r.original.rfqId), + dueDate: newDate, + }) + if (error) { + toast.error(error) + return + } + toast.success("Due date updated") + setConfirmDialogOpen(false) + setCalendarOpen(false) + }) + }, + }) + setConfirmDialogOpen(true) + } + + // 2) Export + function handleExport() { + setAction("export") + startTransition(() => { + exportTableToExcel(table, { + excludeColumns: ["select", "actions"], + onlySelected: true, + }) + }) + } + + // Floating bar UI + return ( + <Portal> + <div className="fixed inset-x-0 bottom-10 z-50 mx-auto w-fit px-2.5"> + <div className="w-full overflow-x-auto"> + <div className="mx-auto flex w-fit items-center gap-2 rounded-md border bg-background p-2 text-foreground shadow"> + {/* Selection Info + Clear */} + <div className="flex h-7 items-center rounded-md border border-dashed pl-2.5 pr-1"> + <span className="whitespace-nowrap text-xs"> + {rows.length} selected + </span> + <Separator orientation="vertical" className="ml-2 mr-1" /> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="icon" + className="size-5 hover:border" + onClick={() => table.toggleAllRowsSelected(false)} + > + <X className="size-3.5 shrink-0" aria-hidden="true" /> + </Button> + </TooltipTrigger> + <TooltipContent className="flex items-center border bg-accent px-2 py-1 font-semibold text-foreground dark:bg-zinc-900"> + <p className="mr-2">Clear selection</p> + <Kbd abbrTitle="Escape" variant="outline"> + Esc + </Kbd> + </TooltipContent> + </Tooltip> + </div> + + <Separator orientation="vertical" className="hidden h-5 sm:block" /> + + <div className="flex items-center gap-1.5"> + {/* 1) Status Update */} + <Select + onValueChange={(value: RfqWithItemCount["status"]) => handleSelectStatus(value)} + > + <Tooltip> + <SelectTrigger asChild> + <TooltipTrigger asChild> + <Button + variant="secondary" + size="icon" + className="size-7 border data-[state=open]:bg-accent data-[state=open]:text-accent-foreground" + disabled={isPending} + > + {isPending && action === "update-status" ? ( + <Loader className="size-3.5 animate-spin" aria-hidden="true" /> + ) : ( + <CheckCircle2 className="size-3.5" aria-hidden="true" /> + )} + </Button> + </TooltipTrigger> + </SelectTrigger> + <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> + <p>Update status</p> + </TooltipContent> + </Tooltip> + <SelectContent align="center"> + <SelectGroup> + {rfqs.status.enumValues.map((status) => ( + <SelectItem key={status} value={status} className="capitalize"> + {status} + </SelectItem> + ))} + </SelectGroup> + </SelectContent> + </Select> + + {/* 2) Due Date Update: Calendar Popover */} + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="secondary" + size="icon" + className="size-7 border" + disabled={isPending} + onClick={() => setCalendarOpen((open) => !open)} + > + {isPending && action === "update-dueDate" ? ( + <Loader className="size-3.5 animate-spin" aria-hidden="true" /> + ) : ( + <CalendarIcon className="size-3.5" aria-hidden="true" /> + )} + </Button> + </TooltipTrigger> + <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> + <p>Update Due Date</p> + </TooltipContent> + </Tooltip> + + {/* Calendar Popover (간단 구현) */} + {calendarOpen && ( + <div className="absolute bottom-16 z-50 rounded-md border bg-background p-2 shadow"> + <Calendar + mode="single" + selected={selectedDate || new Date()} + onSelect={(date) => { + if (date) { + setSelectedDate(date) + handleDueDateSelect(date) + } + }} + initialFocus + /> + </div> + )} + + {/* 3) Export */} + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="secondary" + size="icon" + className="size-7 border" + onClick={handleExport} + disabled={isPending} + > + {isPending && action === "export" ? ( + <Loader className="size-3.5 animate-spin" aria-hidden="true" /> + ) : ( + <Download className="size-3.5" aria-hidden="true" /> + )} + </Button> + </TooltipTrigger> + <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> + <p>Export tasks</p> + </TooltipContent> + </Tooltip> + + {/* 4) Delete */} + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="secondary" + size="icon" + className="size-7 border" + onClick={handleDeleteConfirm} + disabled={isPending} + > + {isPending && action === "delete" ? ( + <Loader className="size-3.5 animate-spin" aria-hidden="true" /> + ) : ( + <Trash2 className="size-3.5" aria-hidden="true" /> + )} + </Button> + </TooltipTrigger> + <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> + <p>Delete tasks</p> + </TooltipContent> + </Tooltip> + </div> + </div> + </div> + </div> + + {/* 공용 Confirm Dialog */} + <ActionConfirmDialog + open={confirmDialogOpen} + onOpenChange={setConfirmDialogOpen} + title={confirmProps.title} + description={confirmProps.description} + onConfirm={confirmProps.onConfirm} + isLoading={ + isPending && (action === "delete" || action === "update-status" || action === "update-dueDate") + } + confirmLabel={ + action === "delete" + ? "Delete" + : action === "update-status" + ? "Update" + : action === "update-dueDate" + ? "Update" + : "Confirm" + } + confirmVariant={action === "delete" ? "destructive" : "default"} + /> + </Portal> + ) +}
\ No newline at end of file diff --git a/lib/rfqs/table/rfqs-table-toolbar-actions.tsx b/lib/rfqs/table/rfqs-table-toolbar-actions.tsx new file mode 100644 index 00000000..6402e625 --- /dev/null +++ b/lib/rfqs/table/rfqs-table-toolbar-actions.tsx @@ -0,0 +1,55 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Download, Upload } from "lucide-react" +import { toast } from "sonner" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" +import { RfqWithItemCount } from "@/db/schema/rfq" +import { DeleteRfqsDialog } from "./delete-rfqs-dialog" +import { AddRfqDialog } from "./add-rfq-dialog" +import { RfqType } from "../validations" + + +interface RfqsTableToolbarActionsProps { + table: Table<RfqWithItemCount> + rfqType?: RfqType; +} + +export function RfqsTableToolbarActions({ table , rfqType = RfqType.PURCHASE}: RfqsTableToolbarActionsProps) { + return ( + <div className="flex items-center gap-2"> + {/** 1) 선택된 로우가 있으면 삭제 다이얼로그 */} + {table.getFilteredSelectedRowModel().rows.length > 0 ? ( + <DeleteRfqsDialog + rfqs={table + .getFilteredSelectedRowModel() + .rows.map((row) => row.original)} + onSuccess={() => table.toggleAllRowsSelected(false)} + /> + ) : null} + + {/** 2) 새 Task 추가 다이얼로그 */} + <AddRfqDialog rfqType={rfqType} /> + + + {/** 4) Export 버튼 */} + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(table, { + filename: "tasks", + excludeColumns: ["select", "actions"], + }) + } + className="gap-2" + > + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Export</span> + </Button> + </div> + ) +}
\ No newline at end of file diff --git a/lib/rfqs/table/rfqs-table.tsx b/lib/rfqs/table/rfqs-table.tsx new file mode 100644 index 00000000..db5c31e7 --- /dev/null +++ b/lib/rfqs/table/rfqs-table.tsx @@ -0,0 +1,264 @@ +"use client" + +import * as React from "react" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" + +import { toSentenceCase } from "@/lib/utils" +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" + +import { getRFQStatusIcon } from "@/lib/tasks/utils" +import { useFeatureFlags } from "./feature-flags-provider" +import { getColumns } from "./rfqs-table-columns" +import { fetchRfqAttachments, fetchRfqItems, getRfqs, getRfqStatusCounts } from "../service" +import { RfqItem, RfqWithItemCount, rfqs } from "@/db/schema/rfq" +import { RfqsTableFloatingBar } from "./rfqs-table-floating-bar" +import { UpdateRfqSheet } from "./update-rfq-sheet" +import { DeleteRfqsDialog } from "./delete-rfqs-dialog" +import { RfqsTableToolbarActions } from "./rfqs-table-toolbar-actions" +import { RfqsItemsDialog } from "./ItemsDialog" +import { getAllItems } from "@/lib/items/service" +import { RfqAttachmentsSheet } from "./attachment-rfq-sheet" +import { useRouter } from "next/navigation" +import { RfqType } from "../validations" + +interface RfqsTableProps { + promises: Promise< + [ + Awaited<ReturnType<typeof getRfqs>>, + Awaited<ReturnType<typeof getRfqStatusCounts>>, + Awaited<ReturnType<typeof getAllItems>>, + ] + >; + rfqType?: RfqType; // rfqType props 추가 +} + +export interface ExistingAttachment { + id: number; + fileName: string; + filePath: string; + createdAt?: Date; + vendorId?: number | null; + size?: number; +} + +export interface ExistingItem { + id?: number; + itemCode: string; + description: string | null; + quantity: number | null; + uom: string | null; +} + +export function RfqsTable({ promises, rfqType = RfqType.PURCHASE }: RfqsTableProps) { + const { featureFlags } = useFeatureFlags() + + const [{ data, pageCount }, statusCounts, items] = React.use(promises) + const [attachmentsOpen, setAttachmentsOpen] = React.useState(false) + const [selectedRfqIdForAttachments, setSelectedRfqIdForAttachments] = React.useState<number | null>(null) + const [attachDefault, setAttachDefault] = React.useState<ExistingAttachment[]>([]) + const [itemsDefault, setItemsDefault] = React.useState<ExistingItem[]>([]) + + const router = useRouter() + + const itemsList = items?.map((v) => ({ + code: v.itemCode ?? "", + name: v.itemName ?? "", + })); + + const [rowAction, setRowAction] = + React.useState<DataTableRowAction<RfqWithItemCount> | null>(null) + + const [rowData, setRowData] = React.useState<RfqWithItemCount[]>(() => data) + + const [itemsModalOpen, setItemsModalOpen] = React.useState(false); + const [selectedRfqId, setSelectedRfqId] = React.useState<number | null>(null); + + + const selectedRfq = React.useMemo(() => { + return rowData.find(row => row.rfqId === selectedRfqId) || null; + }, [rowData, selectedRfqId]); + + // rfqType에 따른 제목 계산 + const getRfqTypeTitle = () => { + return rfqType === RfqType.PURCHASE ? "Purchase RFQ" : "Budgetary RFQ"; + }; + + async function openItemsModal(rfqId: number) { + const itemList = await fetchRfqItems(rfqId) + setItemsDefault(itemList) + setSelectedRfqId(rfqId); + setItemsModalOpen(true); + } + + async function openAttachmentsSheet(rfqId: number) { + // 4.1) Fetch current attachments from server (server action) + const list = await fetchRfqAttachments(rfqId) // returns ExistingAttachment[] + setAttachDefault(list) + setSelectedRfqIdForAttachments(rfqId) + setAttachmentsOpen(true) + setSelectedRfqId(rfqId); + } + + function handleAttachmentsUpdated(rfqId: number, newCount: number, newList?: ExistingAttachment[]) { + // 5.1) update rowData itemCount + setRowData(prev => + prev.map(r => + r.rfqId === rfqId + ? { ...r, itemCount: newCount } + : r + ) + ) + // 5.2) if newList is provided, store it + if (newList) { + setAttachDefault(newList) + } + } + + const columns = React.useMemo(() => getColumns({ + setRowAction, router, + // we pass openItemsModal as a prop so the itemsColumn can call it + openItemsModal, + openAttachmentsSheet, + rfqType + }), [setRowAction, router, rfqType]); + + /** + * This component can render either a faceted filter or a search filter based on the `options` prop. + */ + const filterFields: DataTableFilterField<RfqWithItemCount>[] = [ + { + id: "rfqCode", + label: "RFQ Code", + placeholder: "Filter RFQ Code...", + }, + { + id: "status", + label: "Status", + options: rfqs.status.enumValues?.map((status) => { + // 명시적으로 status를 허용된 리터럴 타입으로 변환 + const s = status as "DRAFT" | "PUBLISHED" | "EVALUATION" | "AWARDED"; + return { + label: toSentenceCase(s), + value: s, + icon: getRFQStatusIcon(s), + count: statusCounts[s], + }; + }), + + } + ] + + /** + * Advanced filter fields for the data table. + */ + const advancedFilterFields: DataTableAdvancedFilterField<RfqWithItemCount>[] = [ + { + id: "rfqCode", + label: "RFQ Code", + type: "text", + }, + { + id: "description", + label: "Description", + type: "text", + }, + { + id: "projectCode", + label: "Project Code", + type: "text", + }, + { + id: "dueDate", + label: "Due Date", + type: "date", + }, + { + id: "status", + label: "Status", + type: "multi-select", + options: rfqs.status.enumValues?.map((status) => { + // 명시적으로 status를 허용된 리터럴 타입으로 변환 + const s = status as "DRAFT" | "PUBLISHED" | "EVALUATION" | "AWARDED"; + return { + label: toSentenceCase(s), + value: s, + icon: getRFQStatusIcon(s), + count: statusCounts[s], + }; + }), + + }, + ] + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.rfqId), + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + <DataTable + table={table} + floatingBar={<RfqsTableFloatingBar table={table} />} + > + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <RfqsTableToolbarActions table={table} rfqType={rfqType} /> + </DataTableAdvancedToolbar> + </DataTable> + + <UpdateRfqSheet + open={rowAction?.type === "update"} + onOpenChange={() => setRowAction(null)} + rfq={rowAction?.row.original ?? null} + rfqType={rfqType} + /> + + <DeleteRfqsDialog + open={rowAction?.type === "delete"} + onOpenChange={() => setRowAction(null)} + rfqs={rowAction?.row.original ? [rowAction?.row.original] : []} + showTrigger={false} + onSuccess={() => rowAction?.row.toggleSelected(false)} + /> + + <RfqsItemsDialog + open={itemsModalOpen} + onOpenChange={setItemsModalOpen} + rfq={selectedRfq ?? null} + itemsList={itemsList} + defaultItems={itemsDefault} + rfqType={rfqType} + /> + + <RfqAttachmentsSheet + open={attachmentsOpen} + onOpenChange={setAttachmentsOpen} + defaultAttachments={attachDefault} + rfqType={rfqType} + rfq={selectedRfq ?? null} + onAttachmentsUpdated={handleAttachmentsUpdated} + /> + </> + ) +}
\ No newline at end of file diff --git a/lib/rfqs/table/update-rfq-sheet.tsx b/lib/rfqs/table/update-rfq-sheet.tsx new file mode 100644 index 00000000..769f25e7 --- /dev/null +++ b/lib/rfqs/table/update-rfq-sheet.tsx @@ -0,0 +1,283 @@ +"use client" + +import * as React from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { Loader } from "lucide-react" +import { useForm } from "react-hook-form" +import { toast } from "sonner" +import { useSession } from "next-auth/react" + +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Input } from "@/components/ui/input" + +import { Rfq, RfqWithItemCount } from "@/db/schema/rfq" +import { RfqType, updateRfqSchema, type UpdateRfqSchema } from "../validations" +import { modifyRfq } from "../service" +import { ProjectSelector } from "@/components/ProjectSelector" +import { type Project } from "../service" +import { BudgetaryRfqSelector } from "./BudgetaryRfqSelector" + +interface UpdateRfqSheetProps + extends React.ComponentPropsWithRef<typeof Sheet> { + rfq: RfqWithItemCount | null + rfqType?: RfqType; +} + + +interface BudgetaryRfq { + id: number; + rfqCode: string; + description: string | null; +} + + +export function UpdateRfqSheet({ rfq,rfqType = RfqType.PURCHASE , ...props }: UpdateRfqSheetProps) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + const { data: session } = useSession() + const userId = Number(session?.user?.id || 1) + const [selectedBudgetaryRfq, setSelectedBudgetaryRfq] = React.useState<BudgetaryRfq | null>(null) + + // RHF setup + const form = useForm<UpdateRfqSchema>({ + resolver: zodResolver(updateRfqSchema), + defaultValues: { + id: rfq?.rfqId ?? 0, // PK + rfqCode: rfq?.rfqCode ?? "", + description: rfq?.description ?? "", + projectId: rfq?.projectId, // 프로젝트 ID + dueDate: rfq?.dueDate ?? undefined, // null을 undefined로 변환 + status: rfq?.status ?? "DRAFT", + createdBy: rfq?.createdBy ?? userId, + }, + }); + + // 프로젝트 선택 처리 + const handleProjectSelect = (project: Project) => { + form.setValue("projectId", project.id); + }; + + async function onSubmit(input: UpdateRfqSchema) { + startUpdateTransition(async () => { + if (!rfq) return + + const { error } = await modifyRfq({ + ...input, + }) + + if (error) { + toast.error(error) + return + } + + form.reset() + props.onOpenChange?.(false) // close the sheet + toast.success("RFQ updated!") + }) + } + + return ( + <Sheet {...props}> + <SheetContent className="flex flex-col gap-6 sm:max-w-md"> + <SheetHeader className="text-left"> + <SheetTitle>Update RFQ</SheetTitle> + <SheetDescription> + Update the RFQ details and save the changes + </SheetDescription> + </SheetHeader> + + {/* RHF Form */} + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4"> + + {/* Hidden or code-based id field */} + <FormField + control={form.control} + name="id" + render={({ field }) => ( + <input type="hidden" {...field} /> + )} + /> + + {/* Project Selector - 재사용 컴포넌트 사용 */} + <FormField + control={form.control} + name="projectId" + render={({ field }) => ( + <FormItem> + <FormLabel>Project</FormLabel> + <FormControl> + <ProjectSelector + selectedProjectId={field.value} + onProjectSelect={handleProjectSelect} + placeholder="프로젝트 선택..." + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Budgetary RFQ Selector - 구매용 RFQ 생성 시에만 표시 */} + {rfqType === RfqType.PURCHASE && ( + <FormField + control={form.control} + name="parentRfqId" + render={({ field }) => ( + <FormItem> + <FormLabel>Budgetary RFQ (Optional)</FormLabel> + <FormControl> + <BudgetaryRfqSelector + selectedRfqId={field.value as number | undefined} + onRfqSelect={(rfq) => { + setSelectedBudgetaryRfq(rfq as any); + form.setValue("parentRfqId", rfq?.id); + }} + placeholder="Budgetary RFQ 선택..." + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + )} + + + {/* rfqCode */} + <FormField + control={form.control} + name="rfqCode" + render={({ field }) => ( + <FormItem> + <FormLabel>RFQ Code</FormLabel> + <FormControl> + <Input placeholder="e.g. RFQ-2025-001" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* description */} + <FormField + control={form.control} + name="description" + render={({ field }) => ( + <FormItem> + <FormLabel>Description</FormLabel> + <FormControl> + <Input placeholder="Description" {...field} value={field.value || ""} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + + + {/* dueDate (type="date") */} + <FormField + control={form.control} + name="dueDate" + render={({ field }) => ( + <FormItem> + <FormLabel>Due Date</FormLabel> + <FormControl> + <Input + type="date" + // convert Date -> yyyy-mm-dd + value={field.value ? field.value.toISOString().slice(0, 10) : ""} + onChange={(e) => { + const val = e.target.value + field.onChange(val ? new Date(val + "T00:00:00") : undefined) + }} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* status (Select) */} + <FormField + control={form.control} + name="status" + render={({ field }) => ( + <FormItem> + <FormLabel>Status</FormLabel> + <FormControl> + <Select + onValueChange={field.onChange} + value={field.value ?? "DRAFT"} + > + <SelectTrigger className="capitalize"> + <SelectValue placeholder="Select status" /> + </SelectTrigger> + <SelectContent> + <SelectGroup> + {["DRAFT", "PUBLISHED", "EVALUATION", "AWARDED"].map((item) => ( + <SelectItem key={item} value={item} className="capitalize"> + {item} + </SelectItem> + ))} + </SelectGroup> + </SelectContent> + </Select> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* createdBy (hidden or read-only) */} + <FormField + control={form.control} + name="createdBy" + render={({ field }) => ( + <input type="hidden" {...field} /> + )} + /> + + <SheetFooter className="gap-2 pt-2 sm:space-x-0"> + <SheetClose asChild> + <Button type="button" variant="outline"> + Cancel + </Button> + </SheetClose> + <Button disabled={isUpdatePending}> + {isUpdatePending && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + Save + </Button> + </SheetFooter> + </form> + </Form> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/lib/rfqs/tbe-table/comments-sheet.tsx b/lib/rfqs/tbe-table/comments-sheet.tsx new file mode 100644 index 00000000..bea1fc8e --- /dev/null +++ b/lib/rfqs/tbe-table/comments-sheet.tsx @@ -0,0 +1,334 @@ +"use client" + +import * as React from "react" +import { useForm, useFieldArray } from "react-hook-form" +import { z } from "zod" +import { zodResolver } from "@hookform/resolvers/zod" +import { Loader, Download, X } from "lucide-react" +import prettyBytes from "pretty-bytes" +import { toast } from "sonner" + +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Textarea, +} from "@/components/ui/textarea" + +import { + Dropzone, + DropzoneZone, + DropzoneUploadIcon, + DropzoneTitle, + DropzoneDescription, + DropzoneInput +} from "@/components/ui/dropzone" + +import { + Table, + TableHeader, + TableRow, + TableHead, + TableBody, + TableCell +} from "@/components/ui/table" + +// DB 스키마에서 필요한 타입들을 가져온다고 가정 +// (실제 프로젝트에 맞춰 import를 수정하세요.) +import { RfqWithAll } from "@/db/schema/rfq" +import { createRfqCommentWithAttachments } from "../service" +import { formatDate } from "@/lib/utils" + +// 코멘트 + 첨부파일 구조 (단순 예시) +// 실제 DB 스키마에 맞춰 조정 +export interface TbeComment { + id: number + commentText: string + commentedBy?: number + createdAt?: string | Date + attachments?: { + id: number + fileName: string + filePath: string + }[] +} + +interface CommentSheetProps extends React.ComponentPropsWithRef<typeof Sheet> { + /** 코멘트를 작성할 RFQ 정보 */ + /** 이미 존재하는 모든 코멘트 목록 (서버에서 불러와 주입) */ + initialComments?: TbeComment[] + + /** 사용자(작성자) ID (로그인 세션 등에서 가져옴) */ + currentUserId: number + rfqId:number + vendorId:number + /** 댓글 저장 후 갱신용 콜백 (옵션) */ + onCommentsUpdated?: (comments: TbeComment[]) => void +} + +// 새 코멘트 작성 폼 스키마 +const commentFormSchema = z.object({ + commentText: z.string().min(1, "댓글을 입력하세요."), + newFiles: z.array(z.any()).optional() // File[] +}) +type CommentFormValues = z.infer<typeof commentFormSchema> + +const MAX_FILE_SIZE = 30e6 // 30MB + +export function CommentSheet({ + rfqId, + vendorId, + initialComments = [], + currentUserId, + onCommentsUpdated, + ...props +}: CommentSheetProps) { + const [comments, setComments] = React.useState<TbeComment[]>(initialComments) + const [isPending, startTransition] = React.useTransition() + + React.useEffect(() => { + setComments(initialComments) + }, [initialComments]) + + + // RHF 세팅 + const form = useForm<CommentFormValues>({ + resolver: zodResolver(commentFormSchema), + defaultValues: { + commentText: "", + newFiles: [] + } + }) + + // formFieldArray 예시 (파일 목록) + const { fields: newFileFields, append, remove } = useFieldArray({ + control: form.control, + name: "newFiles" + }) + + // 1) 기존 코멘트 + 첨부 보여주기 + // 간단히 테이블 하나로 표현 + // 실제로는 Bubble 형태의 UI, Accordion, Timeline 등 다양하게 구성할 수 있음 + function renderExistingComments() { + if (comments.length === 0) { + return <p className="text-sm text-muted-foreground">No comments yet</p> + } + + return ( + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-1/2">Comment</TableHead> + <TableHead>Attachments</TableHead> + <TableHead>Created At</TableHead> + <TableHead>Created By</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {comments.map((c) => ( + <TableRow key={c.id}> + <TableCell>{c.commentText}</TableCell> + <TableCell> + {/* 첨부파일 표시 */} + {(!c.attachments || c.attachments.length === 0) && ( + <span className="text-sm text-muted-foreground">No files</span> + )} + {c.attachments && c.attachments.length > 0 && ( + <div className="flex flex-col gap-1"> + {c.attachments.map((att) => ( + <div key={att.id} className="flex items-center gap-2"> + <a + href={att.filePath} + download + target="_blank" + rel="noreferrer" + className="inline-flex items-center gap-1 text-blue-600 underline" + > + <Download className="h-4 w-4" /> + {att.fileName} + </a> + </div> + ))} + </div> + )} + </TableCell> + <TableCell> { c.createdAt ? formatDate(c.createdAt): "-"}</TableCell> + <TableCell> + {c.commentedBy ?? "-"} + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + ) + } + + // 2) 새 파일 Drop + function handleDropAccepted(files: File[]) { + // 드롭된 File[]을 RHF field array에 추가 + const toAppend = files.map((f) => f) + append(toAppend) + } + + + // 3) 저장(Submit) + async function onSubmit(data: CommentFormValues) { + + if (!rfqId) return + startTransition(async () => { + try { + // 서버 액션 호출 + const res = await createRfqCommentWithAttachments({ + rfqId: rfqId, + vendorId: vendorId, // 필요시 세팅 + commentText: data.commentText, + commentedBy: currentUserId, + evaluationId: null, // 필요시 세팅 + files: data.newFiles + }) + + if (!res.ok) { + throw new Error("Failed to create comment") + } + + toast.success("Comment created") + + // 새 코멘트를 다시 불러오거나, + // 여기서는 임시로 "새로운 코멘트가 추가됐다" 라고 가정하여 클라이언트에서 상태 업데이트 + const newComment: TbeComment = { + id: res.commentId, // 서버에서 반환된 commentId + commentText: data.commentText, + commentedBy: currentUserId, + createdAt: new Date().toISOString(), + attachments: (data.newFiles?.map((f, idx) => ({ + id: Math.random() * 100000, + fileName: f.name, + filePath: "/uploads/" + f.name, + })) || []) + } + setComments((prev) => [...prev, newComment]) + onCommentsUpdated?.([...comments, newComment]) + + // 폼 리셋 + form.reset() + } catch (err: any) { + console.error(err) + toast.error("Error: " + err.message) + } + }) + } + + return ( + <Sheet {...props}> + <SheetContent className="flex flex-col gap-6 sm:max-w-lg"> + <SheetHeader className="text-left"> + <SheetTitle>Comments</SheetTitle> + <SheetDescription> + 필요시 첨부파일과 함께 문의/코멘트를 남길 수 있습니다. + </SheetDescription> + </SheetHeader> + + {/* 기존 코멘트 목록 */} + <div className="max-h-[300px] overflow-y-auto"> + {renderExistingComments()} + </div> + + {/* 새 코멘트 작성 Form */} + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4"> + <FormField + control={form.control} + name="commentText" + render={({ field }) => ( + <FormItem> + <FormLabel>New Comment</FormLabel> + <FormControl> + <Textarea + placeholder="Enter your comment..." + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Dropzone (파일 첨부) */} + <Dropzone + maxSize={MAX_FILE_SIZE} + onDropAccepted={handleDropAccepted} + onDropRejected={(rej) => { + toast.error("File rejected: " + (rej[0]?.file?.name || "")) + }} + > + {({ maxSize }) => ( + <DropzoneZone className="flex justify-center"> + <DropzoneInput /> + <div className="flex items-center gap-6"> + <DropzoneUploadIcon /> + <div className="grid gap-0.5"> + <DropzoneTitle>Drop to attach files</DropzoneTitle> + <DropzoneDescription> + Max size: {prettyBytes(maxSize || 0)} + </DropzoneDescription> + </div> + </div> + </DropzoneZone> + )} + </Dropzone> + + {/* 선택된 파일 목록 */} + {newFileFields.length > 0 && ( + <div className="flex flex-col gap-2"> + {newFileFields.map((field, idx) => { + const file = form.getValues(`newFiles.${idx}`) + if (!file) return null + return ( + <div key={field.id} className="flex items-center justify-between border rounded p-2"> + <span className="text-sm">{file.name} ({prettyBytes(file.size)})</span> + <Button + variant="ghost" + size="icon" + type="button" + onClick={() => remove(idx)} + > + <X className="h-4 w-4" /> + </Button> + </div> + ) + })} + </div> + )} + + <SheetFooter className="gap-2 pt-4"> + <SheetClose asChild> + <Button type="button" variant="outline"> + Cancel + </Button> + </SheetClose> + <Button disabled={isPending}> + {isPending && <Loader className="mr-2 h-4 w-4 animate-spin" />} + Save + </Button> + </SheetFooter> + </form> + </Form> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/lib/rfqs/tbe-table/feature-flags-provider.tsx b/lib/rfqs/tbe-table/feature-flags-provider.tsx new file mode 100644 index 00000000..81131894 --- /dev/null +++ b/lib/rfqs/tbe-table/feature-flags-provider.tsx @@ -0,0 +1,108 @@ +"use client" + +import * as React from "react" +import { useQueryState } from "nuqs" + +import { dataTableConfig, type DataTableConfig } from "@/config/data-table" +import { cn } from "@/lib/utils" +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" + +type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"] + +interface FeatureFlagsContextProps { + featureFlags: FeatureFlagValue[] + setFeatureFlags: (value: FeatureFlagValue[]) => void +} + +const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({ + featureFlags: [], + setFeatureFlags: () => {}, +}) + +export function useFeatureFlags() { + const context = React.useContext(FeatureFlagsContext) + if (!context) { + throw new Error( + "useFeatureFlags must be used within a FeatureFlagsProvider" + ) + } + return context +} + +interface FeatureFlagsProviderProps { + children: React.ReactNode +} + +export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) { + const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>( + "flags", + { + defaultValue: [], + parse: (value) => value.split(",") as FeatureFlagValue[], + serialize: (value) => value.join(","), + eq: (a, b) => + a.length === b.length && a.every((value, index) => value === b[index]), + clearOnDefault: true, + shallow: false, + } + ) + + return ( + <FeatureFlagsContext.Provider + value={{ + featureFlags, + setFeatureFlags: (value) => void setFeatureFlags(value), + }} + > + <div className="w-full overflow-x-auto"> + <ToggleGroup + type="multiple" + variant="outline" + size="sm" + value={featureFlags} + onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)} + className="w-fit gap-0" + > + {dataTableConfig.featureFlags.map((flag, index) => ( + <Tooltip key={flag.value}> + <ToggleGroupItem + value={flag.value} + className={cn( + "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90", + { + "rounded-l-sm border-r-0": index === 0, + "rounded-r-sm": + index === dataTableConfig.featureFlags.length - 1, + } + )} + asChild + > + <TooltipTrigger> + <flag.icon className="size-3.5 shrink-0" aria-hidden="true" /> + {flag.label} + </TooltipTrigger> + </ToggleGroupItem> + <TooltipContent + align="start" + side="bottom" + sideOffset={6} + className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground" + > + <div>{flag.tooltipTitle}</div> + <div className="text-xs text-muted-foreground"> + {flag.tooltipDescription} + </div> + </TooltipContent> + </Tooltip> + ))} + </ToggleGroup> + </div> + {children} + </FeatureFlagsContext.Provider> + ) +} diff --git a/lib/rfqs/tbe-table/file-dialog.tsx b/lib/rfqs/tbe-table/file-dialog.tsx new file mode 100644 index 00000000..1d1a65ea --- /dev/null +++ b/lib/rfqs/tbe-table/file-dialog.tsx @@ -0,0 +1,141 @@ +"use client" + +import * as React from "react" +import { Download, X } from "lucide-react" +import { toast } from "sonner" + +import { getErrorMessage } from "@/lib/handle-error" +import { formatDateTime } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" + +import { + FileList, + FileListItem, + FileListIcon, + FileListInfo, + FileListName, + FileListDescription, + FileListAction, +} from "@/components/ui/file-list" +import { getTbeFilesForVendor, getTbeSubmittedFiles } from "../service" + +interface TBEFileDialogProps { + isOpen: boolean + onOpenChange: (open: boolean) => void + tbeId: number + vendorId: number + rfqId: number + onRefresh?: () => void +} + +export function TBEFileDialog({ + isOpen, + onOpenChange, + vendorId, + rfqId, + onRefresh, +}: TBEFileDialogProps) { + const [submittedFiles, setSubmittedFiles] = React.useState<any[]>([]) + const [isFetchingFiles, setIsFetchingFiles] = React.useState(false) + + + // Fetch submitted files when dialog opens + React.useEffect(() => { + if (isOpen && rfqId && vendorId) { + fetchSubmittedFiles() + } + }, [isOpen, rfqId, vendorId]) + + // Fetch submitted files using the service function + const fetchSubmittedFiles = async () => { + if (!rfqId || !vendorId) return + + setIsFetchingFiles(true) + try { + const { files, error } = await getTbeFilesForVendor(rfqId, vendorId) + + if (error) { + throw new Error(error) + } + + setSubmittedFiles(files) + } catch (error) { + toast.error("Failed to load files: " + getErrorMessage(error)) + } finally { + setIsFetchingFiles(false) + } + } + + // Download submitted file + const downloadSubmittedFile = async (file: any) => { + try { + const response = await fetch(`/api/file/${file.id}/download`) + if (!response.ok) { + throw new Error("Failed to download file") + } + + const blob = await response.blob() + const url = window.URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = file.fileName + document.body.appendChild(a) + a.click() + window.URL.revokeObjectURL(url) + document.body.removeChild(a) + } catch (error) { + toast.error("Failed to download file: " + getErrorMessage(error)) + } + } + + return ( + <Dialog open={isOpen} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-lg"> + <DialogHeader> + <DialogTitle>TBE 응답 파일</DialogTitle> + <DialogDescription>제출된 파일 목록을 확인하고 다운로드하세요.</DialogDescription> + </DialogHeader> + + {/* 제출된 파일 목록 */} + {isFetchingFiles ? ( + <div className="flex justify-center items-center py-8"> + <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div> + </div> + ) : submittedFiles.length > 0 ? ( + <div className="grid gap-2"> + <FileList> + {submittedFiles.map((file) => ( + <FileListItem key={file.id} className="flex items-center justify-between gap-3"> + <div className="flex items-center gap-3 flex-1"> + <FileListIcon className="flex-shrink-0" /> + <FileListInfo className="flex-1 min-w-0"> + <FileListName className="text-sm font-medium truncate">{file.fileName}</FileListName> + <FileListDescription className="text-xs text-muted-foreground"> + {file.uploadedAt ? formatDateTime(file.uploadedAt) : ""} + </FileListDescription> + </FileListInfo> + </div> + <FileListAction className="flex-shrink-0 ml-2"> + <Button variant="ghost" size="icon" onClick={() => downloadSubmittedFile(file)}> + <Download className="h-4 w-4" /> + <span className="sr-only">파일 다운로드</span> + </Button> + </FileListAction> + </FileListItem> + ))} + </FileList> + </div> + ) : ( + <div className="text-center py-8 text-muted-foreground">제출된 파일이 없습니다.</div> + )} + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/rfqs/tbe-table/invite-vendors-dialog.tsx b/lib/rfqs/tbe-table/invite-vendors-dialog.tsx new file mode 100644 index 00000000..e38e0ede --- /dev/null +++ b/lib/rfqs/tbe-table/invite-vendors-dialog.tsx @@ -0,0 +1,203 @@ +"use client" + +import * as React from "react" +import { type Row } from "@tanstack/react-table" +import { Loader, Send } from "lucide-react" +import { toast } from "sonner" + +import { useMediaQuery } from "@/hooks/use-media-query" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer" + +import { Input } from "@/components/ui/input" + +import { VendorWithTbeFields } from "@/config/vendorTbeColumnsConfig" +import { inviteTbeVendorsAction } from "../service" + +interface InviteVendorsDialogProps + extends React.ComponentPropsWithoutRef<typeof Dialog> { + vendors: Row<VendorWithTbeFields>["original"][] + rfqId: number + showTrigger?: boolean + onSuccess?: () => void +} + +export function InviteVendorsDialog({ + vendors, + rfqId, + showTrigger = true, + onSuccess, + ...props +}: InviteVendorsDialogProps) { + const [isInvitePending, startInviteTransition] = React.useTransition() + + + // multiple 파일을 받을 state + const [files, setFiles] = React.useState<FileList | null>(null) + + // 미디어쿼리 (desktop 여부) + const isDesktop = useMediaQuery("(min-width: 640px)") + + function onInvite() { + startInviteTransition(async () => { + // 파일이 선택되지 않았다면 에러 + if (!files || files.length === 0) { + toast.error("Please attach TBE files before inviting.") + return + } + + // FormData 생성 + const formData = new FormData() + formData.append("rfqId", String(rfqId)) + vendors.forEach((vendor) => { + formData.append("vendorIds[]", String(vendor.id)) + }) + + // multiple 파일 + for (let i = 0; i < files.length; i++) { + formData.append("tbeFiles", files[i]) // key는 동일하게 "tbeFiles" + } + + // 서버 액션 호출 + const { error } = await inviteTbeVendorsAction(formData) + + if (error) { + toast.error(error) + return + } + + // 성공 + props.onOpenChange?.(false) + toast.success("Vendors invited with TBE!") + onSuccess?.() + }) + } + + // 파일 선택 UI + const fileInput = ( + <div className="mb-4"> + <label className="mb-2 block font-medium">TBE Sheets</label> + <Input + type="file" + multiple + onChange={(e) => { + setFiles(e.target.files) + }} + /> + </div> + ) + + // Desktop Dialog + if (isDesktop) { + return ( + <Dialog {...props}> + {showTrigger ? ( + <DialogTrigger asChild> + <Button variant="outline" size="sm"> + <Send className="mr-2 size-4" aria-hidden="true" /> + Invite ({vendors.length}) + </Button> + </DialogTrigger> + ) : null} + <DialogContent> + <DialogHeader> + <DialogTitle>Are you absolutely sure?</DialogTitle> + <DialogDescription> + This action cannot be undone. This will permanently invite{" "} + <span className="font-medium">{vendors.length}</span> + {vendors.length === 1 ? " vendor" : " vendors"}. 파일 첨부가 필수이므로 파일을 첨부해야지 버튼이 활성화됩니다. + </DialogDescription> + </DialogHeader> + + {/* 파일 첨부 */} + {fileInput} + + <DialogFooter className="gap-2 sm:space-x-0"> + <DialogClose asChild> + <Button variant="outline">Cancel</Button> + </DialogClose> + <Button + aria-label="Invite selected rows" + variant="destructive" + onClick={onInvite} + // 파일이 없거나 초대 진행중이면 비활성화 + disabled={isInvitePending || !files || files.length === 0} + > + {isInvitePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + Invite + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) + } + + // Mobile Drawer + return ( + <Drawer {...props}> + {showTrigger ? ( + <DrawerTrigger asChild> + <Button variant="outline" size="sm"> + <Send className="mr-2 size-4" aria-hidden="true" /> + Invite ({vendors.length}) + </Button> + </DrawerTrigger> + ) : null} + <DrawerContent> + <DrawerHeader> + <DrawerTitle>Are you absolutely sure?</DrawerTitle> + <DrawerDescription> + This action cannot be undone. This will permanently invite{" "} + <span className="font-medium">{vendors.length}</span> + {vendors.length === 1 ? " vendor" : " vendors"}. + </DrawerDescription> + </DrawerHeader> + + {/* 파일 첨부 */} + {fileInput} + + <DrawerFooter className="gap-2 sm:space-x-0"> + <DrawerClose asChild> + <Button variant="outline">Cancel</Button> + </DrawerClose> + <Button + aria-label="Invite selected rows" + variant="destructive" + onClick={onInvite} + // 파일이 없거나 초대 진행중이면 비활성화 + disabled={isInvitePending || !files || files.length === 0} + > + {isInvitePending && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + Invite + </Button> + </DrawerFooter> + </DrawerContent> + </Drawer> + ) +}
\ No newline at end of file diff --git a/lib/rfqs/tbe-table/tbe-table-columns.tsx b/lib/rfqs/tbe-table/tbe-table-columns.tsx new file mode 100644 index 00000000..29fbd5cd --- /dev/null +++ b/lib/rfqs/tbe-table/tbe-table-columns.tsx @@ -0,0 +1,307 @@ +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { Download, Ellipsis, MessageSquare } from "lucide-react" +import { toast } from "sonner" + +import { getErrorMessage } from "@/lib/handle-error" +import { formatDate } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { useRouter } from "next/navigation" + +import { + VendorTbeColumnConfig, + vendorTbeColumnsConfig, + VendorWithTbeFields, +} from "@/config/vendorTbeColumnsConfig" + +type NextRouter = ReturnType<typeof useRouter> + +interface GetColumnsProps { + setRowAction: React.Dispatch< + React.SetStateAction<DataTableRowAction<VendorWithTbeFields> | null> + > + router: NextRouter + openCommentSheet: (vendorId: number) => void + openFilesDialog: (tbeId:number , vendorId: number) => void +} + +/** + * tanstack table 컬럼 정의 (중첩 헤더 버전) + */ +export function getColumns({ + setRowAction, + router, + openCommentSheet, + openFilesDialog +}: GetColumnsProps): ColumnDef<VendorWithTbeFields>[] { + // ---------------------------------------------------------------- + // 1) Select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef<VendorWithTbeFields> = { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + } + + // ---------------------------------------------------------------- + // 2) 그룹화(Nested) 컬럼 구성 + // ---------------------------------------------------------------- + const groupMap: Record<string, ColumnDef<VendorWithTbeFields>[]> = {} + + vendorTbeColumnsConfig.forEach((cfg) => { + const groupName = cfg.group || "_noGroup" + if (!groupMap[groupName]) { + groupMap[groupName] = [] + } + + // childCol: ColumnDef<VendorWithTbeFields> + const childCol: ColumnDef<VendorWithTbeFields> = { + accessorKey: cfg.id, + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title={cfg.label} /> + ), + meta: { + excelHeader: cfg.excelHeader, + group: cfg.group, + type: cfg.type, + }, + // 셀 렌더링 + cell: ({ row, getValue }) => { + // 1) 필드값 가져오기 + const val = getValue() + + if (cfg.id === "vendorStatus") { + const statusVal = row.original.vendorStatus + if (!statusVal) return null + // const Icon = getStatusIcon(statusVal) + return ( + <Badge variant="outline"> + {statusVal} + </Badge> + ) + } + + + if (cfg.id === "rfqVendorStatus") { + const statusVal = row.original.rfqVendorStatus + if (!statusVal) return null + // const Icon = getStatusIcon(statusVal) + const variant = statusVal ==="INVITED"?"default" :statusVal ==="DECLINED"?"destructive":statusVal ==="ACCEPTED"?"secondary":"outline" + return ( + <Badge variant={variant}> + {statusVal} + </Badge> + ) + } + + // 예) TBE Updated (날짜) + if (cfg.id === "tbeUpdated") { + const dateVal = val as Date | undefined + if (!dateVal) return null + return formatDate(dateVal) + } + + // 그 외 필드는 기본 값 표시 + return val ?? "" + }, + } + + groupMap[groupName].push(childCol) + }) + + // groupMap → nestedColumns + const nestedColumns: ColumnDef<VendorWithTbeFields>[] = [] + Object.entries(groupMap).forEach(([groupName, colDefs]) => { + if (groupName === "_noGroup") { + nestedColumns.push(...colDefs) + } else { + nestedColumns.push({ + id: groupName, + header: groupName, + columns: colDefs, + }) + } + }) + +// ---------------------------------------------------------------- +// 3) Comments 컬럼 +// ---------------------------------------------------------------- +const commentsColumn: ColumnDef<VendorWithTbeFields> = { + id: "comments", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Comments" /> + ), + cell: ({ row }) => { + const vendor = row.original + const commCount = vendor.comments?.length ?? 0 + + function handleClick() { + // rowAction + openCommentSheet + setRowAction({ row, type: "comments" }) + openCommentSheet(vendor.tbeId ?? 0) + } + + return ( + <div className="flex items-center justify-center"> + <Button + variant="ghost" + size="sm" + className="h-8 w-8 p-0 group relative" + onClick={handleClick} + aria-label={commCount > 0 ? `View ${commCount} comments` : "Add comment"} + > + <div className="flex items-center justify-center relative"> + {commCount > 0 ? ( + <> + <MessageSquare className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> + <Badge + variant="secondary" + className="absolute -top-2 -right-2 h-4 min-w-4 text-xs px-1 flex items-center justify-center" + > + {commCount} + </Badge> + </> + ) : ( + <MessageSquare className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> + )} + </div> + <span className="sr-only">{commCount > 0 ? `${commCount} Comments` : "Add Comment"}</span> + </Button> + {/* <span className="ml-2 text-sm text-muted-foreground hover:text-foreground transition-colors cursor-pointer" onClick={handleClick}> + {commCount > 0 ? `${commCount} Comments` : "Add Comment"} + </span> */} + </div> + ) + }, + enableSorting: false, + maxSize:80 +} + + // ---------------------------------------------------------------- + // 4) Actions 컬럼 (예: 초대하기 버튼) + // ---------------------------------------------------------------- + // const actionsColumn: ColumnDef<VendorWithTbeFields> = { + // id: "actions", + // cell: ({ row }) => { + // const status = row.original.tbeResult + // // 예: 만약 tbeResult가 없을 때만 초대하기 버튼 표시 + // if (status) { + // return null + // } + + // return ( + // <Button + // onClick={() => setRowAction({ row, type: "invite" })} + // size="sm" + // variant="outline" + // > + // 발행하기 + // </Button> + // ) + // }, + // size: 80, + // enableSorting: false, + // enableHiding: false, + // } +// ---------------------------------------------------------------- +// 3) Files Column - Add before Comments column +// ---------------------------------------------------------------- +const filesColumn: ColumnDef<VendorWithTbeFields> = { + id: "files", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Response Files" /> + ), + cell: ({ row }) => { + const vendor = row.original + // We'll assume that files count will be populated from the backend + // You'll need to modify your getTBE function to include files + const filesCount = vendor.files?.length ?? 0 + + function handleClick() { + // Open files dialog + setRowAction({ row, type: "files" }) + openFilesDialog(vendor.tbeId ?? 0, vendor.vendorId ?? 0) + } + + return ( + <div className="flex items-center justify-center"> +<Button + variant="ghost" + size="sm" + className="relative h-8 w-8 p-0 group" + onClick={handleClick} + aria-label={filesCount > 0 ? `View ${filesCount} files` : "Upload file"} +> + {/* 아이콘: 중앙 정렬을 위해 Button 자체가 flex container */} + <Download className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> + + {/* 파일 개수가 1개 이상이면 뱃지 표시 */} + {filesCount > 0 && ( + <Badge + variant="secondary" + className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center" + > + {filesCount} + </Badge> + )} + + <span className="sr-only"> + {filesCount > 0 ? `${filesCount} Files` : "Upload File"} + </span> +</Button> + </div> + ) + }, + enableSorting: false, + maxSize: 80 +} + +// ---------------------------------------------------------------- +// 5) 최종 컬럼 배열 - Update to include the files column +// ---------------------------------------------------------------- +return [ + selectColumn, + ...nestedColumns, + filesColumn, // Add the files column before comments + commentsColumn, + // actionsColumn, +] + +}
\ No newline at end of file diff --git a/lib/rfqs/tbe-table/tbe-table-toolbar-actions.tsx b/lib/rfqs/tbe-table/tbe-table-toolbar-actions.tsx new file mode 100644 index 00000000..6a336135 --- /dev/null +++ b/lib/rfqs/tbe-table/tbe-table-toolbar-actions.tsx @@ -0,0 +1,60 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Download, Upload } from "lucide-react" +import { toast } from "sonner" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" + + +import { InviteVendorsDialog } from "./invite-vendors-dialog" +import { VendorWithTbeFields } from "@/config/vendorTbeColumnsConfig" + +interface VendorsTableToolbarActionsProps { + table: Table<VendorWithTbeFields> + rfqId: number +} + +export function VendorsTableToolbarActions({ table,rfqId }: VendorsTableToolbarActionsProps) { + // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식 + const fileInputRef = React.useRef<HTMLInputElement>(null) + + // 파일이 선택되었을 때 처리 + + function handleImportClick() { + // 숨겨진 <input type="file" /> 요소를 클릭 + fileInputRef.current?.click() + } + + return ( + <div className="flex items-center gap-2"> + {table.getFilteredSelectedRowModel().rows.length > 0 ? ( + <InviteVendorsDialog + vendors={table + .getFilteredSelectedRowModel() + .rows.map((row) => row.original)} + rfqId = {rfqId} + onSuccess={() => table.toggleAllRowsSelected(false)} + /> + ) : null} + + + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(table, { + filename: "tasks", + excludeColumns: ["select", "actions"], + }) + } + className="gap-2" + > + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Export</span> + </Button> + </div> + ) +}
\ No newline at end of file diff --git a/lib/rfqs/tbe-table/tbe-table.tsx b/lib/rfqs/tbe-table/tbe-table.tsx new file mode 100644 index 00000000..c385ca0b --- /dev/null +++ b/lib/rfqs/tbe-table/tbe-table.tsx @@ -0,0 +1,190 @@ +"use client" + +import * as React from "react" +import { useRouter } from "next/navigation" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" + +import { toSentenceCase } from "@/lib/utils" +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import { useFeatureFlags } from "./feature-flags-provider" +import { getColumns } from "./tbe-table-columns" +import { Vendor, vendors } from "@/db/schema/vendors" +import { VendorsTableToolbarActions } from "./tbe-table-toolbar-actions" +import { fetchRfqAttachmentsbyCommentId, getTBE } from "../service" +import { InviteVendorsDialog } from "./invite-vendors-dialog" +import { CommentSheet, TbeComment } from "./comments-sheet" +import { VendorWithTbeFields } from "@/config/vendorTbeColumnsConfig" +import { TBEFileDialog } from "./file-dialog" + +interface VendorsTableProps { + promises: Promise< + [ + Awaited<ReturnType<typeof getTBE>>, + ] + > + rfqId: number +} + + +export function TbeTable({ promises, rfqId }: VendorsTableProps) { + const { featureFlags } = useFeatureFlags() + + // Suspense로 받아온 데이터 + const [{ data, pageCount }] = React.use(promises) + + console.log(data) + + const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorWithTbeFields> | null>(null) + + // **router** 획득 + const router = useRouter() + + const [initialComments, setInitialComments] = React.useState<TbeComment[]>([]) + const [commentSheetOpen, setCommentSheetOpen] = React.useState(false) + const [selectedRfqIdForComments, setSelectedRfqIdForComments] = React.useState<number | null>(null) + + const [isFileDialogOpen, setIsFileDialogOpen] = React.useState(false) + const [selectedVendorId, setSelectedVendorId] = React.useState<number | null>(null) + const [selectedTbeId, setSelectedTbeId] = React.useState<number | null>(null) + + console.log(selectedVendorId,"selectedVendorId") + console.log(rfqId,"rfqId") + + // Add handleRefresh function + const handleRefresh = React.useCallback(() => { + router.refresh(); + }, [router]); + + React.useEffect(() => { + if (rowAction?.type === "comments") { + // rowAction가 새로 세팅된 뒤 여기서 openCommentSheet 실행 + openCommentSheet(Number(rowAction.row.original.id)) + } else if (rowAction?.type === "files") { + // Handle files action + const vendorId = rowAction.row.original.vendorId; + const tbeId = rowAction.row.original.tbeId ?? 0; + openFilesDialog(tbeId, vendorId); + } + }, [rowAction]) + + async function openCommentSheet(vendorId: number) { + setInitialComments([]) + + const comments = rowAction?.row.original.comments + + if (comments && comments.length > 0) { + const commentWithAttachments: TbeComment[] = await Promise.all( + comments.map(async (c) => { + const attachments = await fetchRfqAttachmentsbyCommentId(c.id) + + return { + ...c, + commentedBy: 1, // DB나 API 응답에 있다고 가정 + attachments, + } + }) + ) + // 3) state에 저장 -> CommentSheet에서 initialComments로 사용 + setInitialComments(commentWithAttachments) + } + + setSelectedRfqIdForComments(vendorId) + setCommentSheetOpen(true) + } + + const openFilesDialog = (tbeId: number, vendorId: number) => { + setSelectedTbeId(tbeId) + setSelectedVendorId(vendorId) + setIsFileDialogOpen(true) + } + + + // getColumns() 호출 시, router를 주입 + const columns = React.useMemo( + () => getColumns({ setRowAction, router, openCommentSheet, openFilesDialog }), + [setRowAction, router] + ) + + const filterFields: DataTableFilterField<VendorWithTbeFields>[] = [ + ] + + const advancedFilterFields: DataTableAdvancedFilterField<VendorWithTbeFields>[] = [ + { id: "vendorName", label: "Vendor Name", type: "text" }, + { id: "vendorCode", label: "Vendor Code", type: "text" }, + { id: "email", label: "Email", type: "text" }, + { id: "country", label: "Country", type: "text" }, + { + id: "vendorStatus", + label: "Vendor Status", + type: "multi-select", + options: vendors.status.enumValues.map((status) => ({ + label: toSentenceCase(status), + value: status, + })), + }, + { id: "rfqVendorUpdated", label: "Updated at", type: "date" }, + ] + + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "rfqVendorUpdated", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + <DataTable + table={table} + > + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <VendorsTableToolbarActions table={table} rfqId={rfqId} /> + </DataTableAdvancedToolbar> + </DataTable> + <InviteVendorsDialog + vendors={rowAction?.row.original ? [rowAction?.row.original] : []} + onOpenChange={() => setRowAction(null)} + rfqId={rfqId} + open={rowAction?.type === "invite"} + showTrigger={false} + /> + <CommentSheet + currentUserId={1} + open={commentSheetOpen} + onOpenChange={setCommentSheetOpen} + rfqId={rfqId} + vendorId={selectedRfqIdForComments ?? 0} + initialComments={initialComments} + /> + + <TBEFileDialog + isOpen={isFileDialogOpen} + onOpenChange={setIsFileDialogOpen} + tbeId={selectedTbeId ?? 0} + vendorId={selectedVendorId ?? 0} + rfqId={rfqId} // Use the prop directly instead of data[0]?.rfqId + onRefresh={handleRefresh} + /> + </> + ) +}
\ No newline at end of file diff --git a/lib/rfqs/validations.ts b/lib/rfqs/validations.ts new file mode 100644 index 00000000..369e426c --- /dev/null +++ b/lib/rfqs/validations.ts @@ -0,0 +1,274 @@ +import { createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, +} from "nuqs/server" +import * as z from "zod" + +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" +import { Rfq, rfqs, RfqsView, VendorCbeView, VendorRfqViewBase, VendorTbeView } from "@/db/schema/rfq"; +import { Vendor, vendors } from "@/db/schema/vendors"; + +export const RfqType = { + PURCHASE: "PURCHASE", + BUDGETARY: "BUDGETARY" +} as const; + +export type RfqType = typeof RfqType[keyof typeof RfqType]; + +// ======================= +// 1) SearchParams (목록 필터링/정렬) +// ======================= +export const searchParamsCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser<RfqsView>().withDefault([ + { id: "createdAt", desc: true }, + ]), + + // 간단 검색 필드 + rfqCode: parseAsString.withDefault(""), + projectCode: parseAsString.withDefault(""), + projectName: parseAsString.withDefault(""), + dueDate: parseAsString.withDefault(""), + + // 상태 - 여러 개일 수 있다고 가정 + status: parseAsArrayOf(z.enum(rfqs.status.enumValues)).withDefault([]), + + // 고급 필터 + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), + rfqType: parseAsStringEnum(["PURCHASE", "BUDGETARY"]).withDefault("PURCHASE"), + +}); + +export type GetRfqsSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>; + + +export const searchParamsMatchedVCache = createSearchParamsCache({ + // 1) 공통 플래그 + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + + // 2) 페이지네이션 + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + + // 3) 정렬 (Rfq 테이블) + // getSortingStateParser<Rfq>() → Rfq 테이블의 컬럼명에 맞춘 유효성 검사 + sort: getSortingStateParser<VendorRfqViewBase>().withDefault([ + { id: "rfqVendorUpdated", desc: true }, + ]), + + // 4) 간단 검색 필드 + vendorName: parseAsString.withDefault(""), + vendorCode: parseAsString.withDefault(""), + country: parseAsString.withDefault(""), + email: parseAsString.withDefault(""), + website: parseAsString.withDefault(""), + + // 5) 상태 (배열) - Rfq["status"]는 "DRAFT"|"PUBLISHED"|"EVALUATION"|"AWARDED" + // rfqs.status.enumValues 로 가져온 문자열 배열을 z.enum([...])로 처리 + vendorStatus: parseAsArrayOf(z.enum(vendors.status.enumValues)).withDefault([]), + + // 6) 고급 필터 (nuqs - filterColumns) + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + + // 7) 글로벌 검색어 + search: parseAsString.withDefault(""), +}) +export type GetMatchedVendorsSchema = Awaited<ReturnType<typeof searchParamsMatchedVCache.parse>>; + +export const searchParamsTBECache = createSearchParamsCache({ + // 1) 공통 플래그 + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + + // 2) 페이지네이션 + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + + // 3) 정렬 (Rfq 테이블) + // getSortingStateParser<Rfq>() → Rfq 테이블의 컬럼명에 맞춘 유효성 검사 + sort: getSortingStateParser<VendorTbeView>().withDefault([ + { id: "tbeUpdated", desc: true }, + ]), + + // 4) 간단 검색 필드 + vendorName: parseAsString.withDefault(""), + vendorCode: parseAsString.withDefault(""), + country: parseAsString.withDefault(""), + email: parseAsString.withDefault(""), + website: parseAsString.withDefault(""), + + tbeResult: parseAsString.withDefault(""), + tbeNote: parseAsString.withDefault(""), + tbeUpdated: parseAsString.withDefault(""), + rfqType: parseAsStringEnum(["PURCHASE", "BUDGETARY"]).withDefault("PURCHASE"), + + // 5) 상태 (배열) - Rfq["status"]는 "DRAFT"|"PUBLISHED"|"EVALUATION"|"AWARDED" + // rfqs.status.enumValues 로 가져온 문자열 배열을 z.enum([...])로 처리 + vendorStatus: parseAsArrayOf(z.enum(vendors.status.enumValues)).withDefault([]), + + // 6) 고급 필터 (nuqs - filterColumns) + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + + // 7) 글로벌 검색어 + search: parseAsString.withDefault(""), +}) +export type GetTBESchema = Awaited<ReturnType<typeof searchParamsTBECache.parse>>; + +// ======================= +// 2) Create RFQ Schema +// ======================= +export const createRfqSchema = z.object({ + rfqCode: z.string().min(3, "RFQ 코드는 최소 3글자 이상이어야 합니다"), + description: z.string().optional(), + projectId: z.number().nullable().optional(), // 프로젝트 ID (선택적) + parentRfqId: z.number().nullable().optional(), // 부모 RFQ ID (선택적) + dueDate: z.date(), + status: z.enum(["DRAFT", "PUBLISHED", "EVALUATION", "AWARDED"]), + rfqType: z.enum([RfqType.PURCHASE, RfqType.BUDGETARY]).default(RfqType.PURCHASE), + createdBy: z.number(), +}); + +export type CreateRfqSchema = z.infer<typeof createRfqSchema>; + +export const createRfqItemSchema = z.object({ + rfqId: z.number().int().min(1, "Invalid RFQ ID"), + itemCode: z.string().min(1), + itemName: z.string().optional(), + description: z.string().optional(), + quantity: z.number().min(1).optional(), + uom: z.string().optional(), + rfqType: z.string().default("PURCHASE"), // rfqType 필드 추가 + +}); + +export type CreateRfqItemSchema = z.infer<typeof createRfqItemSchema>; + +// ======================= +// 3) Update RFQ Schema +// (현재 코드엔 updateTaskSchema라고 되어 있는데, +// RFQ 업데이트이므로 'updateRfqSchema'라 명명하는 게 자연스러움) +// ======================= +export const updateRfqSchema = z.object({ + // PK id -> 실제로는 URL params로 받을 수도 있지만, + // 여기서는 body에서 받는다고 가정 + id: z.number().int().min(1, "Invalid ID"), + + // 업데이트 시 대부분 optional + rfqCode: z.string().max(50).optional(), + projectId: z.number().nullable().optional(), // null 값도 허용 + description: z.string().optional(), + parentRfqId: z.number().nullable().optional(), // 부모 RFQ ID (선택적) + dueDate: z.preprocess( + // null이나 빈 문자열을 undefined로 변환 + (val) => (val === null || val === '') ? undefined : val, + z.date().optional() + ), + status: z.union([ + z.enum(["DRAFT", "PUBLISHED", "EVALUATION", "AWARDED"]), + z.string().refine( + (val) => ["DRAFT", "PUBLISHED", "EVALUATION", "AWARDED"].includes(val), + { message: "Invalid status value" } + ) + ]).optional(), + createdBy: z.number().int().min(1).optional(), +}); +export type UpdateRfqSchema = z.infer<typeof updateRfqSchema>; + +export const searchParamsRfqsForVendorsCache = createSearchParamsCache({ + // 1) 공통 플래그 + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + + // 2) 페이지네이션 + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + + // 3) 정렬 (rfqs 테이블) + sort: getSortingStateParser<Rfq>().withDefault([ + { id: "createdAt", desc: true }, + ]), + + // 4) 간단 검색 필드 (예: rfqCode, projectName, projectCode 등) + rfqCode: parseAsString.withDefault(""), + projectCode: parseAsString.withDefault(""), + projectName: parseAsString.withDefault(""), + + // 5) 상태 배열 (rfqs.status.enumValues: "DRAFT" | "PUBLISHED" | ...) + status: parseAsArrayOf(z.enum(rfqs.status.enumValues)).withDefault([]), + + // 6) 고급 필터 (nuqs filterColumns) + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + + // 7) 글로벌 검색어 + search: parseAsString.withDefault(""), +}) + +/** + * 최종 타입 + * `Awaited<ReturnType<...parse>>` 형태로 + * Next.js 13 서버 액션이나 클라이언트에서 사용 가능 + */ +export type GetRfqsForVendorsSchema = Awaited<ReturnType<typeof searchParamsRfqsForVendorsCache.parse>> + +export const updateRfqVendorSchema = z.object({ + id: z.number().int().min(1, "Invalid ID"), // rfq_vendors.id + status: z.enum(["INVITED","ACCEPTED","DECLINED","REVIEWING", "RESPONDED"]) +}) + +export type UpdateRfqVendorSchema = z.infer<typeof updateRfqVendorSchema> + + + + +export const searchParamsCBECache = createSearchParamsCache({ + // 1) 공통 플래그 + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + + // 2) 페이지네이션 + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + + // 3) 정렬 (Rfq 테이블) + // getSortingStateParser<Rfq>() → Rfq 테이블의 컬럼명에 맞춘 유효성 검사 + sort: getSortingStateParser<VendorCbeView>().withDefault([ + { id: "cbeUpdated", desc: true }, + ]), + + // 4) 간단 검색 필드 + vendorName: parseAsString.withDefault(""), + vendorCode: parseAsString.withDefault(""), + country: parseAsString.withDefault(""), + email: parseAsString.withDefault(""), + website: parseAsString.withDefault(""), + + cbeResult: parseAsString.withDefault(""), + cbeNote: parseAsString.withDefault(""), + cbeUpdated: parseAsString.withDefault(""), + rfqType: parseAsStringEnum(["PURCHASE", "BUDGETARY"]).withDefault("PURCHASE"), + + + totalCost: parseAsInteger.withDefault(0), + currency: parseAsString.withDefault(""), + paymentTerms: parseAsString.withDefault(""), + incoterms: parseAsString.withDefault(""), + deliverySchedule: parseAsString.withDefault(""), + + // 5) 상태 (배열) - Rfq["status"]는 "DRAFT"|"PUBLISHED"|"EVALUATION"|"AWARDED" + // rfqs.status.enumValues 로 가져온 문자열 배열을 z.enum([...])로 처리 + vendorStatus: parseAsArrayOf(z.enum(vendors.status.enumValues)).withDefault([]), + + // 6) 고급 필터 (nuqs - filterColumns) + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + + // 7) 글로벌 검색어 + search: parseAsString.withDefault(""), +}) +export type GetCBESchema = Awaited<ReturnType<typeof searchParamsCBECache.parse>>; diff --git a/lib/rfqs/vendor-table/add-vendor-dialog.tsx b/lib/rfqs/vendor-table/add-vendor-dialog.tsx new file mode 100644 index 00000000..8ec5b9f4 --- /dev/null +++ b/lib/rfqs/vendor-table/add-vendor-dialog.tsx @@ -0,0 +1,37 @@ +"use client" + +import * as React from "react" +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { VendorsListTable } from "./vendor-list/vendor-list-table" + +interface VendorsListTableProps { + rfqId: number // so we know which RFQ to insert into + } + + +/** + * A dialog that contains a client-side table or infinite scroll + * for "all vendors," allowing the user to select vendors and add them to the RFQ. + */ +export function AddVendorDialog({ rfqId }: VendorsListTableProps) { + const [open, setOpen] = React.useState(false) + + return ( + <Dialog open={open} onOpenChange={setOpen}> + <DialogTrigger asChild> + <Button size="sm"> + Add Vendor + </Button> + </DialogTrigger> + <DialogContent className="max-w-[90wv] sm:max-h-[80vh] overflow-auto" style={{maxWidth:1600, height:680}}> + <DialogHeader> + <DialogTitle>Add Vendor to RFQ</DialogTitle> + </DialogHeader> + + <VendorsListTable rfqId={rfqId}/> + + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/rfqs/vendor-table/comments-sheet.tsx b/lib/rfqs/vendor-table/comments-sheet.tsx new file mode 100644 index 00000000..644869c6 --- /dev/null +++ b/lib/rfqs/vendor-table/comments-sheet.tsx @@ -0,0 +1,303 @@ +"use client" + +import * as React from "react" +import { useForm, useFieldArray } from "react-hook-form" +import { z } from "zod" +import { zodResolver } from "@hookform/resolvers/zod" +import { Loader, Download, X } from "lucide-react" +import prettyBytes from "pretty-bytes" +import { toast } from "sonner" + +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Textarea } from "@/components/ui/textarea" +import { + Dropzone, + DropzoneZone, + DropzoneUploadIcon, + DropzoneTitle, + DropzoneDescription, + DropzoneInput, +} from "@/components/ui/dropzone" +import { + Table, + TableHeader, + TableRow, + TableHead, + TableBody, + TableCell, +} from "@/components/ui/table" + +import { createRfqCommentWithAttachments } from "../service" +import { formatDate } from "@/lib/utils" + + +export interface MatchedVendorComment { + id: number + commentText: string + commentedBy?: number + createdAt?: Date + attachments?: { + id: number + fileName: string + filePath: string + }[] +} + +// 1) props 정의 +interface CommentSheetProps extends React.ComponentPropsWithRef<typeof Sheet> { + initialComments?: MatchedVendorComment[] + currentUserId: number + rfqId: number + vendorId: number + onCommentsUpdated?: (comments: MatchedVendorComment[]) => void +} + +// 2) 폼 스키마 +const commentFormSchema = z.object({ + commentText: z.string().min(1, "댓글을 입력하세요."), + newFiles: z.array(z.any()).optional(), // File[] +}) +type CommentFormValues = z.infer<typeof commentFormSchema> + +const MAX_FILE_SIZE = 30e6 // 30MB + +export function CommentSheet({ + rfqId, + vendorId, + initialComments = [], + currentUserId, + onCommentsUpdated, + ...props +}: CommentSheetProps) { + const [comments, setComments] = React.useState<MatchedVendorComment[]>(initialComments) + const [isPending, startTransition] = React.useTransition() + + React.useEffect(() => { + setComments(initialComments) + }, [initialComments]) + + const form = useForm<CommentFormValues>({ + resolver: zodResolver(commentFormSchema), + defaultValues: { + commentText: "", + newFiles: [], + }, + }) + + const { fields: newFileFields, append, remove } = useFieldArray({ + control: form.control, + name: "newFiles", + }) + + // (A) 기존 코멘트 렌더링 + function renderExistingComments() { + if (comments.length === 0) { + return <p className="text-sm text-muted-foreground">No comments yet</p> + } + return ( + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-1/2">Comment</TableHead> + <TableHead>Attachments</TableHead> + <TableHead>Created At</TableHead> + <TableHead>Created By</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {comments.map((c) => ( + <TableRow key={c.id}> + <TableCell>{c.commentText}</TableCell> + <TableCell> + {!c.attachments?.length && ( + <span className="text-sm text-muted-foreground">No files</span> + )} + {c.attachments?.length && ( + <div className="flex flex-col gap-1"> + {c.attachments.map((att) => ( + <div key={att.id} className="flex items-center gap-2"> + <a + href={att.filePath} + download + target="_blank" + rel="noreferrer" + className="inline-flex items-center gap-1 text-blue-600 underline" + > + <Download className="h-4 w-4" /> + {att.fileName} + </a> + </div> + ))} + </div> + )} + </TableCell> + <TableCell> { c.createdAt ? formatDate(c.createdAt): "-"}</TableCell> + <TableCell>{c.commentedBy ?? "-"}</TableCell> + </TableRow> + ))} + </TableBody> + </Table> + ) + } + + // (B) 파일 드롭 + function handleDropAccepted(files: File[]) { + append(files) + } + + // (C) Submit + async function onSubmit(data: CommentFormValues) { + if (!rfqId) return + startTransition(async () => { + try { + const res = await createRfqCommentWithAttachments({ + rfqId, + vendorId, + commentText: data.commentText, + commentedBy: currentUserId, + evaluationId: null, + files: data.newFiles, + }) + + if (!res.ok) { + throw new Error("Failed to create comment") + } + + toast.success("Comment created") + + // 임시로 새 코멘트 추가 + const newComment: MatchedVendorComment = { + id: res.commentId, // 서버 응답 + commentText: data.commentText, + commentedBy: currentUserId, + createdAt: res.createdAt, + attachments: + data.newFiles?.map((f) => ({ + id: Math.floor(Math.random() * 1e6), + fileName: f.name, + filePath: "/uploads/" + f.name, + })) || [], + } + setComments((prev) => [...prev, newComment]) + onCommentsUpdated?.([...comments, newComment]) + + form.reset() + } catch (err: any) { + console.error(err) + toast.error("Error: " + err.message) + } + }) + } + + return ( + <Sheet {...props}> + <SheetContent className="flex flex-col gap-6 sm:max-w-lg"> + <SheetHeader className="text-left"> + <SheetTitle>Comments</SheetTitle> + <SheetDescription> + 필요시 첨부파일과 함께 문의/코멘트를 남길 수 있습니다. + </SheetDescription> + </SheetHeader> + + <div className="max-h-[300px] overflow-y-auto">{renderExistingComments()}</div> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4"> + <FormField + control={form.control} + name="commentText" + render={({ field }) => ( + <FormItem> + <FormLabel>New Comment</FormLabel> + <FormControl> + <Textarea placeholder="Enter your comment..." {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <Dropzone + maxSize={MAX_FILE_SIZE} + onDropAccepted={handleDropAccepted} + onDropRejected={(rej) => { + toast.error("File rejected: " + (rej[0]?.file?.name || "")) + }} + > + {({ maxSize }) => ( + <DropzoneZone className="flex justify-center"> + <DropzoneInput /> + <div className="flex items-center gap-6"> + <DropzoneUploadIcon /> + <div className="grid gap-0.5"> + <DropzoneTitle>Drop to attach files</DropzoneTitle> + <DropzoneDescription> + Max size: {prettyBytes(maxSize || 0)} + </DropzoneDescription> + </div> + </div> + </DropzoneZone> + )} + </Dropzone> + + {newFileFields.length > 0 && ( + <div className="flex flex-col gap-2"> + {newFileFields.map((field, idx) => { + const file = form.getValues(`newFiles.${idx}`) + if (!file) return null + return ( + <div + key={field.id} + className="flex items-center justify-between border rounded p-2" + > + <span className="text-sm"> + {file.name} ({prettyBytes(file.size)}) + </span> + <Button + variant="ghost" + size="icon" + type="button" + onClick={() => remove(idx)} + > + <X className="h-4 w-4" /> + </Button> + </div> + ) + })} + </div> + )} + + <SheetFooter className="gap-2 pt-4"> + <SheetClose asChild> + <Button type="button" variant="outline"> + Cancel + </Button> + </SheetClose> + <Button disabled={isPending}> + {isPending && <Loader className="mr-2 h-4 w-4 animate-spin" />} + Save + </Button> + </SheetFooter> + </form> + </Form> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/lib/rfqs/vendor-table/feature-flags-provider.tsx b/lib/rfqs/vendor-table/feature-flags-provider.tsx new file mode 100644 index 00000000..81131894 --- /dev/null +++ b/lib/rfqs/vendor-table/feature-flags-provider.tsx @@ -0,0 +1,108 @@ +"use client" + +import * as React from "react" +import { useQueryState } from "nuqs" + +import { dataTableConfig, type DataTableConfig } from "@/config/data-table" +import { cn } from "@/lib/utils" +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" + +type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"] + +interface FeatureFlagsContextProps { + featureFlags: FeatureFlagValue[] + setFeatureFlags: (value: FeatureFlagValue[]) => void +} + +const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({ + featureFlags: [], + setFeatureFlags: () => {}, +}) + +export function useFeatureFlags() { + const context = React.useContext(FeatureFlagsContext) + if (!context) { + throw new Error( + "useFeatureFlags must be used within a FeatureFlagsProvider" + ) + } + return context +} + +interface FeatureFlagsProviderProps { + children: React.ReactNode +} + +export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) { + const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>( + "flags", + { + defaultValue: [], + parse: (value) => value.split(",") as FeatureFlagValue[], + serialize: (value) => value.join(","), + eq: (a, b) => + a.length === b.length && a.every((value, index) => value === b[index]), + clearOnDefault: true, + shallow: false, + } + ) + + return ( + <FeatureFlagsContext.Provider + value={{ + featureFlags, + setFeatureFlags: (value) => void setFeatureFlags(value), + }} + > + <div className="w-full overflow-x-auto"> + <ToggleGroup + type="multiple" + variant="outline" + size="sm" + value={featureFlags} + onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)} + className="w-fit gap-0" + > + {dataTableConfig.featureFlags.map((flag, index) => ( + <Tooltip key={flag.value}> + <ToggleGroupItem + value={flag.value} + className={cn( + "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90", + { + "rounded-l-sm border-r-0": index === 0, + "rounded-r-sm": + index === dataTableConfig.featureFlags.length - 1, + } + )} + asChild + > + <TooltipTrigger> + <flag.icon className="size-3.5 shrink-0" aria-hidden="true" /> + {flag.label} + </TooltipTrigger> + </ToggleGroupItem> + <TooltipContent + align="start" + side="bottom" + sideOffset={6} + className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground" + > + <div>{flag.tooltipTitle}</div> + <div className="text-xs text-muted-foreground"> + {flag.tooltipDescription} + </div> + </TooltipContent> + </Tooltip> + ))} + </ToggleGroup> + </div> + {children} + </FeatureFlagsContext.Provider> + ) +} diff --git a/lib/rfqs/vendor-table/invite-vendors-dialog.tsx b/lib/rfqs/vendor-table/invite-vendors-dialog.tsx new file mode 100644 index 00000000..23853e2f --- /dev/null +++ b/lib/rfqs/vendor-table/invite-vendors-dialog.tsx @@ -0,0 +1,177 @@ +"use client" + +import * as React from "react" +import { type Row } from "@tanstack/react-table" +import { Loader, Send, Trash, AlertTriangle } from "lucide-react" +import { toast } from "sonner" + +import { useMediaQuery } from "@/hooks/use-media-query" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer" +import { Alert, AlertDescription } from "@/components/ui/alert" + +import { MatchedVendorRow } from "@/config/vendorRfbColumnsConfig" +import { inviteVendors } from "../service" +import { RfqType } from "@/lib/rfqs/validations" + +interface DeleteTasksDialogProps + extends React.ComponentPropsWithoutRef<typeof Dialog> { + vendors: Row<MatchedVendorRow>["original"][] + rfqId:number + rfqType: RfqType + showTrigger?: boolean + onSuccess?: () => void +} + +export function InviteVendorsDialog({ + vendors, + rfqId, + rfqType, + showTrigger = true, + onSuccess, + ...props +}: DeleteTasksDialogProps) { + const [isInvitePending, startInviteTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + + function onDelete() { + startInviteTransition(async () => { + const { error } = await inviteVendors({ + rfqId, + vendorIds: vendors.map((vendor) => Number(vendor.id)), + rfqType + }) + + if (error) { + toast.error(error) + return + } + + props.onOpenChange?.(false) + toast.success("Vendor invited") + onSuccess?.() + }) + } + + if (isDesktop) { + return ( + <Dialog {...props}> + {showTrigger ? ( + <DialogTrigger asChild> + <Button variant="outline" size="sm"> + <Send className="mr-2 size-4" aria-hidden="true" /> + Invite ({vendors.length}) + </Button> + </DialogTrigger> + ) : null} + <DialogContent> + <DialogHeader> + <DialogTitle>Are you absolutely sure?</DialogTitle> + <DialogDescription> + This action cannot be undone. This will permanently invite{" "} + <span className="font-medium">{vendors.length}</span> + {vendors.length === 1 ? " vendor" : " vendors"}. + </DialogDescription> + </DialogHeader> + + {/* 편집 제한 경고 메시지 */} + <Alert variant="destructive" className="mt-4"> + <AlertTriangle className="h-4 w-4" /> + <AlertDescription className="font-medium"> + 한 업체라도 초대를 하고 나면 아이템 편집과 RFQ 문서 첨부 편집은 불가능합니다. + </AlertDescription> + </Alert> + + <DialogFooter className="gap-2 sm:space-x-0 mt-6"> + <DialogClose asChild> + <Button variant="outline">Cancel</Button> + </DialogClose> + <Button + aria-label="Invite selected rows" + variant="destructive" + onClick={onDelete} + disabled={isInvitePending} + > + {isInvitePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + Invite + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) + } + + return ( + <Drawer {...props}> + {showTrigger ? ( + <DrawerTrigger asChild> + <Button variant="outline" size="sm"> + <Trash className="mr-2 size-4" aria-hidden="true" /> + Invite ({vendors.length}) + </Button> + </DrawerTrigger> + ) : null} + <DrawerContent> + <DrawerHeader> + <DrawerTitle>Are you absolutely sure?</DrawerTitle> + <DrawerDescription> + This action cannot be undone. This will permanently invite {" "} + <span className="font-medium">{vendors.length}</span> + {vendors.length === 1 ? " vendor" : " vendors"} from our servers. + </DrawerDescription> + </DrawerHeader> + + {/* 편집 제한 경고 메시지 (모바일용) */} + <div className="px-4"> + <Alert variant="destructive"> + <AlertTriangle className="h-4 w-4" /> + <AlertDescription className="font-medium"> + 한 업체라도 초대를 하고 나면 아이템 편집과 RFQ 문서 첨부 편집은 불가능합니다. + </AlertDescription> + </Alert> + </div> + + <DrawerFooter className="gap-2 sm:space-x-0 mt-4"> + <DrawerClose asChild> + <Button variant="outline">Cancel</Button> + </DrawerClose> + <Button + aria-label="Delete selected rows" + variant="destructive" + onClick={onDelete} + disabled={isInvitePending} + > + {isInvitePending && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + Invite + </Button> + </DrawerFooter> + </DrawerContent> + </Drawer> + ) +}
\ No newline at end of file diff --git a/lib/rfqs/vendor-table/vendor-list/vendor-list-table-column.tsx b/lib/rfqs/vendor-table/vendor-list/vendor-list-table-column.tsx new file mode 100644 index 00000000..bfcbe75b --- /dev/null +++ b/lib/rfqs/vendor-table/vendor-list/vendor-list-table-column.tsx @@ -0,0 +1,154 @@ +"use client" +// Because columns rely on React state/hooks for row actions + +import * as React from "react" +import { ColumnDef, Row } from "@tanstack/react-table" +import { VendorData } from "./vendor-list-table" +import { ClientDataTableColumnHeaderSimple } from "@/components/client-data-table/data-table-column-simple-header" +import { formatDate } from "@/lib/utils" +import { Checkbox } from "@/components/ui/checkbox" + +export interface DataTableRowAction<TData> { + row: Row<TData> + type: "open" | "update" | "delete" +} + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<VendorData> | null>> + setSelectedVendorIds: React.Dispatch<React.SetStateAction<number[]>> // Changed to array +} + +/** getColumns: return array of ColumnDef for 'vendors' data */ +export function getColumns({ + setRowAction, + setSelectedVendorIds, // Changed parameter name +}: GetColumnsProps): ColumnDef<VendorData>[] { + return [ + // MULTIPLE SELECT COLUMN + { + id: "select", + enableSorting: false, + enableHiding: false, + size: 40, + // Add checkbox in header for select all functionality + header: ({ table }) => ( + <Checkbox + checked={ + table.getFilteredSelectedRowModel().rows.length > 0 && + table.getFilteredSelectedRowModel().rows.length === table.getFilteredRowModel().rows.length + } + onCheckedChange={(checked) => { + table.toggleAllRowsSelected(!!checked) + + // Update selectedVendorIds based on all rows selection + if (checked) { + const allIds = table.getFilteredRowModel().rows.map(row => row.original.id) + setSelectedVendorIds(allIds) + } else { + setSelectedVendorIds([]) + } + }} + aria-label="Select all" + /> + ), + cell: ({ row }) => { + const isSelected = row.getIsSelected() + + return ( + <Checkbox + checked={isSelected} + onCheckedChange={(checked) => { + row.toggleSelected(!!checked) + + // Update the selectedVendorIds state by adding or removing this ID + setSelectedVendorIds(prevIds => { + if (checked) { + // Add this ID if it doesn't exist + return prevIds.includes(row.original.id) + ? prevIds + : [...prevIds, row.original.id] + } else { + // Remove this ID + return prevIds.filter(id => id !== row.original.id) + } + }) + }} + aria-label="Select row" + /> + ) + }, + }, + + // Vendor Name + { + accessorKey: "vendorName", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="Vendor Name" /> + ), + cell: ({ row }) => row.getValue("vendorName"), + }, + + // Vendor Code + { + accessorKey: "vendorCode", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="Vendor Code" /> + ), + cell: ({ row }) => row.getValue("vendorCode"), + }, + + // Status + { + accessorKey: "status", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="Status" /> + ), + cell: ({ row }) => row.getValue("status"), + }, + + // Country + { + accessorKey: "country", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="Country" /> + ), + cell: ({ row }) => row.getValue("country"), + }, + + // Email + { + accessorKey: "email", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="Email" /> + ), + cell: ({ row }) => row.getValue("email"), + }, + + // Phone + { + accessorKey: "phone", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="Phone" /> + ), + cell: ({ row }) => row.getValue("phone"), + }, + + // Created At + { + accessorKey: "createdAt", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="Created At" /> + ), + cell: ({ cell }) => formatDate(cell.getValue() as Date), + }, + + // Updated At + { + accessorKey: "updatedAt", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="Updated At" /> + ), + cell: ({ cell }) => formatDate(cell.getValue() as Date), + }, + ] +}
\ No newline at end of file diff --git a/lib/rfqs/vendor-table/vendor-list/vendor-list-table.tsx b/lib/rfqs/vendor-table/vendor-list/vendor-list-table.tsx new file mode 100644 index 00000000..c436eebd --- /dev/null +++ b/lib/rfqs/vendor-table/vendor-list/vendor-list-table.tsx @@ -0,0 +1,142 @@ +"use client" + +import * as React from "react" +import { ClientDataTable } from "@/components/client-data-table/data-table" +import { DataTableRowAction, getColumns } from "./vendor-list-table-column" +import { DataTableAdvancedFilterField } from "@/types/table" +import { addItemToVendors, getAllVendors } from "../../service" +import { Loader2, Plus } from "lucide-react" +import { Button } from "@/components/ui/button" +import { useToast } from "@/hooks/use-toast" + +export interface VendorData { + id: number + vendorName: string + vendorCode: string | null + taxId: string + address: string | null + country: string | null + phone: string | null + email: string | null + website: string | null + status: string + createdAt: Date + updatedAt: Date +} + +interface VendorsListTableProps { + rfqId: number +} + +export function VendorsListTable({ rfqId }: VendorsListTableProps) { + const { toast } = useToast() + const [rowAction, setRowAction] = + React.useState<DataTableRowAction<VendorData> | null>(null) + + // Changed to array for multiple selection + const [selectedVendorIds, setSelectedVendorIds] = React.useState<number[]>([]) + const [isSubmitting, setIsSubmitting] = React.useState(false) + + const columns = React.useMemo( + () => getColumns({ setRowAction, setSelectedVendorIds }), + [setRowAction, setSelectedVendorIds] + ) + + const [vendors, setVendors] = React.useState<VendorData[]>([]) + const [isLoading, setIsLoading] = React.useState(false) + + React.useEffect(() => { + async function loadAllVendors() { + setIsLoading(true) + try { + const allVendors = await getAllVendors() + setVendors(allVendors) + } catch (error) { + console.error("벤더 목록 로드 오류:", error) + toast({ + title: "Error", + description: "Failed to load vendors", + variant: "destructive", + }) + } finally { + setIsLoading(false) + } + } + loadAllVendors() + }, [toast]) + + const advancedFilterFields: DataTableAdvancedFilterField<VendorData>[] = [] + + async function handleAddVendors() { + if (selectedVendorIds.length === 0) return // Safety check + + setIsSubmitting(true) + try { + // Update to use the multiple vendor service + const result = await addItemToVendors(rfqId, selectedVendorIds) + + if (result.success) { + toast({ + title: "Success", + description: `Added items to ${selectedVendorIds.length} vendors`, + }) + // Reset selection after successful addition + setSelectedVendorIds([]) + } else { + toast({ + title: "Error", + description: result.error || "Failed to add items to vendors", + variant: "destructive", + }) + } + } catch (err) { + console.error("Failed to add vendors:", err) + toast({ + title: "Error", + description: "An unexpected error occurred", + variant: "destructive", + }) + } finally { + setIsSubmitting(false) + } + } + + // If loading, show a flex container that fills the parent and centers the spinner + if (isLoading) { + return ( + <div className="flex h-full w-full items-center justify-center"> + <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /> + </div> + ) + } + + // Otherwise, show the table + return ( + <ClientDataTable + data={vendors} + columns={columns} + advancedFilterFields={advancedFilterFields} + > + <div className="flex items-center gap-2"> + <Button + variant="default" + size="sm" + onClick={handleAddVendors} + disabled={selectedVendorIds.length === 0 || isSubmitting} + > + {isSubmitting ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + Adding... + </> + ) : ( + <> + <Plus className="mr-2 h-4 w-4" /> + Add Vendors ({selectedVendorIds.length}) + </> + )} + </Button> + </div> + </ClientDataTable> + ) +}
\ No newline at end of file diff --git a/lib/rfqs/vendor-table/vendors-table-columns.tsx b/lib/rfqs/vendor-table/vendors-table-columns.tsx new file mode 100644 index 00000000..1220cb9d --- /dev/null +++ b/lib/rfqs/vendor-table/vendors-table-columns.tsx @@ -0,0 +1,264 @@ +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { Ellipsis } from "lucide-react" +import { toast } from "sonner" + +import { getErrorMessage } from "@/lib/handle-error" +import { formatDate } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header" +import { useRouter } from "next/navigation" + +import { vendors } from "@/db/schema/vendors" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { vendorColumnsConfig } from "@/config/vendorColumnsConfig" +import { Separator } from "@/components/ui/separator" +import { MatchedVendorRow, vendorRfqColumnsConfig } from "@/config/vendorRfbColumnsConfig" + + +type NextRouter = ReturnType<typeof useRouter>; + + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<MatchedVendorRow> | null>>; + router: NextRouter; + openCommentSheet: (rfqId: number) => void; + +} + +/** + * tanstack table 컬럼 정의 (중첩 헤더 버전) + */ +export function getColumns({ setRowAction, router, openCommentSheet }: GetColumnsProps): ColumnDef<MatchedVendorRow>[] { + // ---------------------------------------------------------------- + // 1) select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef<MatchedVendorRow> = { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + } + + // ---------------------------------------------------------------- + // 2) actions 컬럼 (Dropdown 메뉴) + // ---------------------------------------------------------------- + + // ---------------------------------------------------------------- + // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 + // ---------------------------------------------------------------- + // 3-1) groupMap: { [groupName]: ColumnDef<MatchedVendorRow>[] } + const groupMap: Record<string, ColumnDef<MatchedVendorRow>[]> = {} + + vendorRfqColumnsConfig.forEach((cfg) => { + // 만약 group가 없으면 "_noGroup" 처리 + const groupName = cfg.group || "_noGroup" + + if (!groupMap[groupName]) { + groupMap[groupName] = [] + } + + // child column 정의 + const childCol: ColumnDef<MatchedVendorRow> = { + accessorKey: cfg.id, + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title={cfg.label} /> + ), + meta: { + excelHeader: cfg.excelHeader, + group: cfg.group, + type: cfg.type, + }, + cell: ({ row, cell }) => { + + + if (cfg.id === "vendorStatus") { + const statusVal = row.original.vendorStatus + if (!statusVal) return null + // const Icon = getStatusIcon(statusVal) + return ( + <Badge variant="outline"> + {statusVal} + </Badge> + ) + } + + if (cfg.id === "rfqVendorStatus") { + const statusVal = row.original.rfqVendorStatus + if (!statusVal) return null + // const Icon = getStatusIcon(statusVal) + const variant = statusVal === "INVITED" ? "default" : statusVal === "REJECTED" ? "destructive" : statusVal === "ACCEPTED" ? "secondary" : "outline" + return ( + <Badge variant={variant}> + {statusVal} + </Badge> + ) + } + + + if (cfg.id === "rfqVendorUpdated") { + const dateVal = cell.getValue() as Date + if (!dateVal) return null + return formatDate(dateVal) + } + + + // code etc... + return row.getValue(cfg.id) ?? "" + }, + } + + groupMap[groupName].push(childCol) + }) + + const actionsColumn: ColumnDef<MatchedVendorRow> = { + id: "actions", + // header: "Actions", + cell: ({ row }) => { + const rfq = row.original + const commCount = rfq.comments?.length ?? 0 + const status = row.original.rfqVendorStatus + + // 공통 코멘트 핸들러 + function handleCommentClick() { + setRowAction({ row, type: "comments" }) + openCommentSheet(Number(row.original.id)) + } + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-8 p-0 data-[state=open]:bg-muted" + > + <Ellipsis className="size-4" aria-hidden="true" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-40"> + + {/* 기존 기능: status가 INVITED일 때만 표시 */} + {(!status || status === 'INVITED') && ( + <DropdownMenuItem onSelect={() => setRowAction({ row, type: "invite" })}> + 발행하기 + </DropdownMenuItem> + )} + {/* 두 기능 사이 구분선 */} + <DropdownMenuSeparator /> + {/* 코멘트 메뉴 항목 */} + <DropdownMenuItem onSelect={handleCommentClick}> + {commCount > 0 ? `${commCount} Comments` : "Add Comment"} + </DropdownMenuItem> + + + </DropdownMenuContent> + </DropdownMenu> + ) + }, + size: 40, + enableSorting: false, + enableHiding: false, + } + + + // const commentsColumn: ColumnDef<MatchedVendorRow> = { + // id: "comments", + // header: "Comments", + // cell: ({ row }) => { + // const rfq = row.original + // const commCount = rfq.comments?.length ?? 0 + + // // 공통 클릭 핸들러 + // function handleClick() { + // setRowAction({ row, type: "comments" }) + // openCommentSheet(Number(row.original.id)) + // } + + // return commCount > 0 ? ( + // <a + // href="#" + // onClick={(e) => { + // e.preventDefault() + // handleClick() + // }} + // > + // {commCount} Comments + // </a> + // ) : ( + // <Button size="sm" variant="outline" onClick={handleClick}> + // Add Comment + // </Button> + // ) + // }, + // } + // ---------------------------------------------------------------- + // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기 + // ---------------------------------------------------------------- + const nestedColumns: ColumnDef<MatchedVendorRow>[] = [] + + // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함 + // 여기서는 그냥 Object.entries 순서 + Object.entries(groupMap).forEach(([groupName, colDefs]) => { + if (groupName === "_noGroup") { + // 그룹 없음 → 그냥 최상위 레벨 컬럼 + nestedColumns.push(...colDefs) + } else { + // 상위 컬럼 + nestedColumns.push({ + id: groupName, + header: groupName, // "Basic Info", "Metadata" 등 + columns: colDefs, + }) + } + }) + + // ---------------------------------------------------------------- + // 4) 최종 컬럼 배열: select, nestedColumns, actions + // ---------------------------------------------------------------- + return [ + selectColumn, + ...nestedColumns, + // commentsColumn, + actionsColumn + + ] +}
\ No newline at end of file diff --git a/lib/rfqs/vendor-table/vendors-table-floating-bar.tsx b/lib/rfqs/vendor-table/vendors-table-floating-bar.tsx new file mode 100644 index 00000000..9b32cf5f --- /dev/null +++ b/lib/rfqs/vendor-table/vendors-table-floating-bar.tsx @@ -0,0 +1,137 @@ +"use client" + +import * as React from "react" +import { SelectTrigger } from "@radix-ui/react-select" +import { type Table } from "@tanstack/react-table" +import { + ArrowUp, + CheckCircle2, + Download, + Loader, + Trash2, + X, +} from "lucide-react" +import { toast } from "sonner" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" +import { Portal } from "@/components/ui/portal" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, +} from "@/components/ui/select" +import { Separator } from "@/components/ui/separator" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" +import { Kbd } from "@/components/kbd" + +import { ActionConfirmDialog } from "@/components/ui/action-dialog" +import { vendors } from "@/db/schema/vendors" +import { MatchedVendorRow } from "@/config/vendorRfbColumnsConfig" + +interface VendorsTableFloatingBarProps { + table: Table<MatchedVendorRow> +} + + +export function VendorsTableFloatingBar({ table }: VendorsTableFloatingBarProps) { + const rows = table.getFilteredSelectedRowModel().rows + + const [isPending, startTransition] = React.useTransition() + const [action, setAction] = React.useState< + "update-status" | "export" | "delete" + >() + const [popoverOpen, setPopoverOpen] = React.useState(false) + + // Clear selection on Escape key press + React.useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + table.toggleAllRowsSelected(false) + } + } + + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [table]) + + + + // 공용 confirm dialog state + const [confirmDialogOpen, setConfirmDialogOpen] = React.useState(false) + const [confirmProps, setConfirmProps] = React.useState<{ + title: string + description?: string + onConfirm: () => Promise<void> | void + }>({ + title: "", + description: "", + onConfirm: () => { }, + }) + + + + + + return ( + <Portal > + <div className="fixed inset-x-0 bottom-10 z-50 mx-auto w-fit px-2.5" style={{ bottom: '1.5rem' }}> + <div className="w-full overflow-x-auto"> + <div className="mx-auto flex w-fit items-center gap-2 rounded-md border bg-background p-2 text-foreground shadow"> + <div className="flex h-7 items-center rounded-md border border-dashed pl-2.5 pr-1"> + <span className="whitespace-nowrap text-xs"> + {rows.length} selected + </span> + <Separator orientation="vertical" className="ml-2 mr-1" /> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="icon" + className="size-5 hover:border" + onClick={() => table.toggleAllRowsSelected(false)} + > + <X className="size-3.5 shrink-0" aria-hidden="true" /> + </Button> + </TooltipTrigger> + <TooltipContent className="flex items-center border bg-accent px-2 py-1 font-semibold text-foreground dark:bg-zinc-900"> + <p className="mr-2">Clear selection</p> + <Kbd abbrTitle="Escape" variant="outline"> + Esc + </Kbd> + </TooltipContent> + </Tooltip> + </div> + + </div> + </div> + </div> + + + {/* 공용 Confirm Dialog */} + <ActionConfirmDialog + open={confirmDialogOpen} + onOpenChange={setConfirmDialogOpen} + title={confirmProps.title} + description={confirmProps.description} + onConfirm={confirmProps.onConfirm} + isLoading={isPending && (action === "delete" || action === "update-status")} + confirmLabel={ + action === "delete" + ? "Delete" + : action === "update-status" + ? "Update" + : "Confirm" + } + confirmVariant={ + action === "delete" ? "destructive" : "default" + } + /> + </Portal> + ) +} diff --git a/lib/rfqs/vendor-table/vendors-table-toolbar-actions.tsx b/lib/rfqs/vendor-table/vendors-table-toolbar-actions.tsx new file mode 100644 index 00000000..abb34f85 --- /dev/null +++ b/lib/rfqs/vendor-table/vendors-table-toolbar-actions.tsx @@ -0,0 +1,84 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" + +import { MatchedVendorRow } from "@/config/vendorRfbColumnsConfig" +import { InviteVendorsDialog } from "./invite-vendors-dialog" +import { AddVendorDialog } from "./add-vendor-dialog" +import { Button } from "@/components/ui/button" +import { useToast } from "@/hooks/use-toast" + +interface VendorsTableToolbarActionsProps { + table: Table<MatchedVendorRow> + rfqId: number +} + +export function VendorsTableToolbarActions({ table, rfqId }: VendorsTableToolbarActionsProps) { + const { toast } = useToast() + const fileInputRef = React.useRef<HTMLInputElement>(null) + + // 선택된 모든 행 + const selectedRows = table.getFilteredSelectedRowModel().rows + + // 조건에 맞는 벤더만 필터링 + const eligibleVendors = React.useMemo(() => { + return selectedRows + .map(row => row.original) + .filter(vendor => !vendor.rfqVendorStatus || vendor.rfqVendorStatus === "INVITED") + }, [selectedRows]) + + // 조건에 맞지 않는 벤더 수 + const ineligibleCount = selectedRows.length - eligibleVendors.length + + function handleImportClick() { + fileInputRef.current?.click() + } + + function handleInviteClick() { + // 조건에 맞지 않는 벤더가 있다면 토스트 메시지 표시 + if (ineligibleCount > 0) { + toast({ + title: "일부 벤더만 초대됩니다", + description: `선택한 ${selectedRows.length}개 중 ${eligibleVendors.length}개만 초대 가능합니다. 나머지 ${ineligibleCount}개는 초대 불가능한 상태입니다.`, + // variant: "warning", + }) + } + } + + // 다이얼로그 표시 여부 - 적합한 벤더가 1개 이상 있으면 표시 + const showInviteDialog = eligibleVendors.length > 0 + + return ( + <div className="flex items-center gap-2"> + {selectedRows.length > 0 && ( + <> + {showInviteDialog ? ( + <InviteVendorsDialog + vendors={eligibleVendors} + rfqId={rfqId} + onSuccess={() => table.toggleAllRowsSelected(false)} + onOpenChange={(open) => { + // 다이얼로그가 열릴 때만 경고 표시 + if (open && ineligibleCount > 0) { + handleInviteClick() + } + }} + /> + ) : ( + <Button + variant="default" + size="sm" + disabled={true} + title="선택된 벤더 중 초대 가능한 벤더가 없습니다" + > + 초대 불가 + </Button> + )} + </> + )} + + <AddVendorDialog rfqId={rfqId} /> + </div> + ) +}
\ No newline at end of file diff --git a/lib/rfqs/vendor-table/vendors-table.tsx b/lib/rfqs/vendor-table/vendors-table.tsx new file mode 100644 index 00000000..838342bf --- /dev/null +++ b/lib/rfqs/vendor-table/vendors-table.tsx @@ -0,0 +1,181 @@ +"use client" + +import * as React from "react" +import { useRouter } from "next/navigation" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" + +import { toSentenceCase } from "@/lib/utils" +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import { useFeatureFlags } from "./feature-flags-provider" +import { getColumns } from "./vendors-table-columns" +import { vendors } from "@/db/schema/vendors" +import { VendorsTableToolbarActions } from "./vendors-table-toolbar-actions" +import { VendorsTableFloatingBar } from "./vendors-table-floating-bar" +import { fetchRfqAttachmentsbyCommentId, getMatchedVendors } from "../service" +import { InviteVendorsDialog } from "./invite-vendors-dialog" +import { CommentSheet, MatchedVendorComment } from "./comments-sheet" +import { MatchedVendorRow } from "@/config/vendorRfbColumnsConfig" +import { RfqType } from "@/lib/rfqs/validations" + +interface VendorsTableProps { + promises: Promise<[Awaited<ReturnType<typeof getMatchedVendors>>]> + rfqId: number + rfqType: RfqType +} + +export function MatchedVendorsTable({ promises, rfqId, rfqType}: VendorsTableProps) { + const { featureFlags } = useFeatureFlags() + + // 1) Suspense로 받아온 데이터 + const [{ data, pageCount }] = React.use(promises) + // data는 MatchedVendorRow[] 형태 (getMatchedVendors에서 반환) + + console.log(data) + + // 2) Row 액션 상태 + const [rowAction, setRowAction] = React.useState< + DataTableRowAction<MatchedVendorRow> | null + >(null) + + // **router** 획득 + const router = useRouter() + + // 3) CommentSheet 에 넣을 상태 + // => “댓글”은 MatchedVendorComment[] 로 관리해야 함 + const [initialComments, setInitialComments] = React.useState< + MatchedVendorComment[] + >([]) + const [commentSheetOpen, setCommentSheetOpen] = React.useState(false) + const [selectedVendorIdForComments, setSelectedVendorIdForComments] = + React.useState<number | null>(null) + + // 4) rowAction이 바뀌면, type이 "comments"인지 확인 후 open + React.useEffect(() => { + if (rowAction?.type === "comments") { + openCommentSheet(rowAction.row.original.id) + } + }, [rowAction]) + + // 5) 댓글 시트 오픈 함수 + async function openCommentSheet(vendorId: number) { + setInitialComments([]) + + // (a) 현재 Row의 comments 불러옴 + const comments = rowAction?.row.original.comments + if (comments && comments.length > 0) { + // (b) 각 comment마다 첨부파일 fetch + const commentWithAttachments: MatchedVendorComment[] = await Promise.all( + comments.map(async (c) => { + const attachments = await fetchRfqAttachmentsbyCommentId(c.id) + return { + ...c, + attachments, + } + }) + ) + setInitialComments(commentWithAttachments) + } + + // (c) vendorId state + setSelectedVendorIdForComments(vendorId) + setCommentSheetOpen(true) + } + + // 6) 컬럼 정의 (memo) + const columns = React.useMemo( + () => getColumns({ setRowAction, router, openCommentSheet }), + [setRowAction, router] + ) + + // 7) 필터 정의 + const filterFields: DataTableFilterField<MatchedVendorRow>[] = [] + + const advancedFilterFields: DataTableAdvancedFilterField<MatchedVendorRow>[] = [ + { id: "vendorName", label: "Vendor Name", type: "text" }, + { id: "vendorCode", label: "Vendor Code", type: "text" }, + { id: "email", label: "Email", type: "text" }, + { id: "country", label: "Country", type: "text" }, + { + id: "vendorStatus", + label: "Vendor Status", + type: "multi-select", + options: vendors.status.enumValues.map((status) => ({ + label: toSentenceCase(status), + value: status, + })), + }, + { + id: "rfqVendorStatus", + label: "RFQ Status", + type: "multi-select", + options: ["INVITED", "ACCEPTED", "REJECTED", "QUOTED"].map((s) => ({ + label: s, + value: s, + })), + }, + { id: "rfqVendorUpdated", label: "Updated at", type: "date" }, + ] + + // 8) 테이블 생성 + const { table } = useDataTable({ + data, // MatchedVendorRow[] + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "rfqVendorUpdated", desc: true }], + columnPinning: { right: ["actions"] }, + }, + // 행의 고유 ID + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + <DataTable table={table}> + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <VendorsTableToolbarActions table={table} rfqId={rfqId} /> + </DataTableAdvancedToolbar> + </DataTable> + + {/* 초대 다이얼로그 */} + <InviteVendorsDialog + vendors={rowAction?.row.original ? [rowAction?.row.original] : []} + onOpenChange={() => setRowAction(null)} + rfqId={rfqId} + open={rowAction?.type === "invite"} + showTrigger={false} + rfqType={rfqType} + /> + + {/* 댓글 시트 */} + <CommentSheet + open={commentSheetOpen} + onOpenChange={setCommentSheetOpen} + initialComments={initialComments} + rfqId={rfqId} + vendorId={selectedVendorIdForComments ?? 0} + currentUserId={1} + onCommentsUpdated={(updatedComments) => { + // Row 의 comments 필드도 업데이트 + if (!rowAction?.row) return + rowAction.row.original.comments = updatedComments + }} + /> + </> + ) +}
\ No newline at end of file diff --git a/lib/roles/repository.ts b/lib/roles/repository.ts new file mode 100644 index 00000000..99ffdf29 --- /dev/null +++ b/lib/roles/repository.ts @@ -0,0 +1,94 @@ +// repository.ts +import { sql, and, eq, inArray ,desc,asc} from "drizzle-orm"; +import type { PgTransaction } from "drizzle-orm/pg-core"; +import { roles, users, userRoles, Role, roleView, RoleView } from "@/db/schema/users"; // 수정 +import db from "@/db/db"; +import { companies } from "@/db/schema/companies"; + +export type NewRole = typeof roles.$inferInsert; // User insert 시 필요한 타입 + + +// (A) SELECT roles + userCount +export async function selectRolesWithUserCount( + tx: PgTransaction<any, any, any>, + params: { + where?: any + orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[] + offset?: number, + limit?: number, + } +) { + + const { where, orderBy, offset = 0, limit = 10 } = params + + const query = tx + .select() + .from(roleView) + .where(where) + .orderBy(...(orderBy ?? [])) + .offset(offset) + .limit(limit) + + const rows = await query + return rows +} +// (B) countRoles +export async function countRoles( +tx: PgTransaction<any, any, any>, + where?: ReturnType<typeof and> +) { + // COUNT(*) from roles + const [{ count }] = await tx + .select({ count: sql<number>`COUNT(*)`.as("count") }) + .from(roles) + .where(where ?? undefined); + + return count; // number +} + +export async function insertRole( + tx: PgTransaction<any, any, any>, + data: NewRole +) { + return tx.insert(roles).values(data).returning(); +} + +export const getRoleById = async (id: number): Promise<Role | null> => { + const roleFouned = await db.select().from(roles).where(eq(roles.id, id)).execute(); + if (roleFouned.length === 0) return null; + + const role = roleFouned[0]; + return role +}; + + +export async function updateRole( + tx: PgTransaction<any, any, any>, + roleId: number, + data: Partial<Role> +) { + return tx + .update(roles) + .set(data) + .where(eq(roles.id, roleId)) + .returning(); +} + + +export async function deleteRolesByIds( + tx: PgTransaction<any, any, any>, + ids: number[] +) { + return tx.delete(roles).where(inArray(roles.id, ids)); +} + +export async function deleteUserRolesByIds( + tx: PgTransaction<any, any, any>, + ids: number[] +) { + return tx.delete(userRoles).where(inArray(userRoles.roleId, ids)); +} + +export async function findAllRoleView(domain?: "evcp" | "partners"): Promise<RoleView[]> { + return db.select().from(roleView).where(eq(roleView.domain,domain)).orderBy(asc(roleView.name)); +}
\ No newline at end of file diff --git a/lib/roles/services.ts b/lib/roles/services.ts new file mode 100644 index 00000000..1a91d4fa --- /dev/null +++ b/lib/roles/services.ts @@ -0,0 +1,300 @@ +"use server"; + +import { revalidateTag, unstable_cache, unstable_noStore } from "next/cache"; +import db from "@/db/db"; +import { permissions, Role, rolePermissions, roles, RoleView, roleView, userRoles } from "@/db/schema/users"; +import { and, or, asc, desc, ilike, eq, inArray } from "drizzle-orm"; +import { filterColumns } from "@/lib/filter-columns"; +import { + selectRolesWithUserCount, + countRoles, + insertRole, + getRoleById, + updateRole, + deleteRolesByIds, + deleteUserRolesByIds, + findAllRoleView, +} from "./repository"; +import { CreateRoleSchema, GetRolesSchema, UpdateRoleSchema } from "./validations"; +import { getErrorMessage } from "@/lib/handle-error"; + +interface UpsertPermissionsInput { + roleIds: number[]; + permissionKeys: string[]; + itemTitle?: string; +} + +export async function getRolesWithCount(input: GetRolesSchema) { + // unstable_cache: 특정 키와 함께 캐싱 + return unstable_cache( + async () => { + try { + // 1) pagination + const offset = (input.page - 1) * input.perPage; + + // 2) advanced filter + const advancedWhere = filterColumns({ + table: roleView, // 또는 roleView + filters: input.filters, + joinOperator: input.joinOperator, + }); + + // 3) 글로벌 검색 + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + // 예: roles.name 에 ilike 검색 + globalWhere = or(ilike(roles.name, s)); + } + + // 4) 최종 where + const finalWhere = and(advancedWhere, globalWhere); + + // (5) 정렬 + const orderBy = + input.sort.length > 0 + ? input.sort.map((item) => + item.desc ? desc(roleView[item.id]) : asc(roleView[item.id]) + ) + : [desc(roleView.created_at)]; + + + // 6) 트랜잭션 + Repository 호출 + const { data, total } = await db.transaction(async (tx) => { + // 실제 SELECT + const data = await selectRolesWithUserCount(tx, { + where: finalWhere, + orderBy, + offset, + limit: input.perPage, + }); + + // 전체 개수 + const total = await countRoles(tx, finalWhere); + + return { data, total }; + }); + + // 7) pageCount + const pageCount = Math.ceil(total / input.perPage); + + return { data, pageCount }; + } catch (err) { + // 에러시 기본값 + return { data: [], pageCount: 0 }; + } + }, + [JSON.stringify(input)], // 캐싱 키 + { + revalidate: 3600, + tags: ["roles"], // revalidateTag("roles")로 무효화 + } + )(); +} + +export async function createRole(input: CreateRoleSchema) { + unstable_noStore(); // 캐싱 방지(Next.js 서버 액션용) + try { + + await db.transaction(async (tx) => { + const [newRole] = await insertRole(tx, { + name: input.name, + domain: input.domain, + description: input.description ?? "", + companyId: input.domain === "partners" ? input.companyId ?? null : null, + }); + }); + + revalidateTag("roles"); + + return { data: null, error: null }; + + } catch (err) { + return { data: null, error: getErrorMessage(err) }; + } +} + + +export async function modifiRole(input: UpdateRoleSchema & { id: number }) { + unstable_noStore(); + + try { + + const data = await db.transaction(async (tx) => { + // 1) 먼저 User 테이블 업데이트 + const [res] = await updateRole(tx, input.id, { + name: input.name, + description: input.description, + domain: input.domain + }); + + return res; + }); + + // 3) 캐시 무효화 + revalidateTag("roles"); + + + return { data: null, error: null }; + } catch (err) { + return { data: null, error: getErrorMessage(err) }; + } +} + +export async function removeRoles(input: { ids: number[] }) { + unstable_noStore(); + + try { + await db.transaction(async (tx) => { + // user_roles도 있으면 먼저 삭제해야 할 수 있음 + + await deleteUserRolesByIds(tx, input.ids); + await deleteRolesByIds(tx, input.ids); + + }); + + revalidateTag("roles"); + revalidateTag("user-role-counts"); + revalidateTag("users"); + + return { data: null, error: null }; + } catch (err) { + return { data: null, error: getErrorMessage(err) }; + } +} + + + +export async function assignRolesToUsers(roleIds: number[], userIds: number[]) { + // Next.js 서버 액션에서 캐싱 방지 + unstable_noStore() + + try { + await db.transaction(async (tx) => { + // 1) 기존 userRoles 삭제: userIds, roleIds에 해당하는 레코드만 + await tx + .delete(userRoles) + .where( + and( + inArray(userRoles.roleId, roleIds), + inArray(userRoles.userId, userIds) + ) + ) + + // 2) 새로 삽입 + if (roleIds.length > 0 && userIds.length > 0) { + const newRows = [] + for (const rid of roleIds) { + for (const uid of userIds) { + newRows.push({ roleId: rid, userId: uid }) + } + } + await tx.insert(userRoles).values(newRows) + } + }) + + // 캐시 무효화 + revalidateTag("users") + revalidateTag("roles") + + return { data: null, error: null } + } catch (err) { + return { data: null, error: getErrorMessage(err) } + } +} + +export async function getAllRoleView(domain?: "evcp" | "partners"): Promise<RoleView[]> { + try { + return await findAllRoleView(domain) + } catch (err) { + throw new Error("Failed to get roles") + } +} + +export async function upsertPermissions(input: UpsertPermissionsInput) { + unstable_noStore(); + try { + const { roleIds, permissionKeys, itemTitle } = input; + if (!roleIds.length || !permissionKeys.length) { + return; // nothing to do + } + + const roleIdNums = roleIds + + await db.transaction(async (tx) => { + for (const permKey of permissionKeys) { + // A) Check if permissionKey exists in "permissions" table + const [existingPerm] = await tx + .select({ id: permissions.id }) + .from(permissions) + .where(eq(permissions.permissionKey, permKey)) + .limit(1); + + let permissionId: number; + if (!existingPerm) { + // Insert new permission + // description를 어떻게 만들지는 자유: itemTitle + permKey 등 + const [inserted] = await tx + .insert(permissions) + .values({ + permissionKey: permKey, + description: itemTitle ? `Menu: ${itemTitle} perm: ${permKey}` : permKey, + }) + .returning({ id: permissions.id }); + + permissionId = inserted.id; + } else { + permissionId = existingPerm.id; + } + + // B) now link (roleId, permissionId) in role_permissions + for (const rId of roleIdNums) { + // check if already exists + const [rp] = await tx + .select({ p: rolePermissions.permissionId }) + .from(rolePermissions) + .where(and(eq(rolePermissions.roleId, rId), eq(rolePermissions.permissionId, permissionId))) + .limit(1); + + if (!rp) { + // insert + await tx.insert(rolePermissions).values({ + roleId: rId, + permissionId, + }); + } + // if rp exists, skip + } + } + }); + + return { data: null, error: null }; + + } catch (err) { + return { data: null, error: getErrorMessage(err) }; + } +} + + +export async function getMenuPermissions( + itemKey: string +): Promise<{ roleId: number; permKey: string }[]> { + // itemKey = "alert-dialog" + // permKey = "alert-dialog.create", "alert-dialog.viewOwn", ... + const pattern = `${itemKey}.%` + + // SELECT rp.role_id, p.permission_key + // FROM role_permissions rp + // JOIN permissions p ON p.id = rp.permissionId + // WHERE p.permission_key LIKE 'alert-dialog.%' + const rows = await db + .select({ + roleId: rolePermissions.roleId, + permKey: permissions.permissionKey, + }) + .from(rolePermissions) + .innerJoin(permissions, eq(permissions.id, rolePermissions.permissionId)) + .where(ilike(permissions.permissionKey, pattern)); + + return rows; +}
\ No newline at end of file diff --git a/lib/roles/table/add-role-dialog.tsx b/lib/roles/table/add-role-dialog.tsx new file mode 100644 index 00000000..365daf29 --- /dev/null +++ b/lib/roles/table/add-role-dialog.tsx @@ -0,0 +1,308 @@ +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Check, ChevronsUpDown, Loader } from "lucide-react" +import { cn } from "@/lib/utils" +import { toast } from "sonner" + +import { createRoleSchema, type CreateRoleSchema } from "../validations" +import { createRole } from "../services" +import { Textarea } from "@/components/ui/textarea" +import { Company } from "@/db/schema/companies" +import { getAllCompanies } from "@/lib/admin-users/service" +import { + Popover, + PopoverTrigger, + PopoverContent, +} from "@/components/ui/popover" +import { + Command, + CommandInput, + CommandList, + CommandGroup, + CommandItem, + CommandEmpty, +} from "@/components/ui/command" + + + +const domainOptions = [ + { value: "partners", label: "협력업체" }, + { value: "evcp", label: "삼성중공업" }, +] + +export function AddRoleDialog() { + const [open, setOpen] = React.useState(false) + const [isAddPending, startAddTransition] = React.useTransition() + const [companies, setCompanies] = React.useState<Company[]>([]) // 회사 목록 + + React.useEffect(() => { + getAllCompanies().then((res) => { + setCompanies(res) + }) + }, []) + + // react-hook-form 세팅 + const form = useForm<CreateRoleSchema>({ + resolver: zodResolver(createRoleSchema), + defaultValues: { + name: "", + domain: "evcp", // 기본값 + description: "", + // companyId: null, // optional + }, + }) + + async function onSubmit(data: CreateRoleSchema) { + startAddTransition(async () => { + const result = await createRole(data) + if (result.error) { + toast.error(`에러: ${result.error}`) + return + } + form.reset() + setOpen(false) + toast.success("Role added") + }) + } + + function handleDialogOpenChange(nextOpen: boolean) { + if (!nextOpen) { + form.reset() + } + setOpen(nextOpen) + } + + // domain이 partners일 경우 companyId 입력 필드 보이게 + const selectedDomain = form.watch("domain") + + return ( + <Dialog open={open} onOpenChange={handleDialogOpenChange}> + <DialogTrigger asChild> + <Button variant="default" size="sm"> + Add Role + </Button> + </DialogTrigger> + + <DialogContent> + <DialogHeader> + <DialogTitle>Create New Role</DialogTitle> + <DialogDescription> + 새 Role 정보를 입력하고 <b>Create</b> 버튼을 누르세요. + </DialogDescription> + </DialogHeader> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)}> + <div className="space-y-4 py-4"> + {/* 1) Role Name */} + <FormField + control={form.control} + name="name" + render={({ field }) => ( + <FormItem> + <FormLabel>Role Name</FormLabel> + <FormControl> + <Input + placeholder="e.g. admin" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 2) Description */} + <FormField + control={form.control} + name="description" + render={({ field }) => ( + <FormItem> + <FormLabel>Role Description</FormLabel> + <FormControl> + <Textarea + placeholder="Describe role" + className="resize-none" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 3) Domain Select */} + <FormField + control={form.control} + name="domain" + render={({ field }) => ( + <FormItem> + <FormLabel>Domain</FormLabel> + <FormControl> + <Select + // domain이 바뀔 때마다 form state에도 반영 + onValueChange={field.onChange} + value={field.value} + > + <SelectTrigger> + <SelectValue placeholder="Select Domain" /> + </SelectTrigger> + <SelectContent> + {domainOptions.map((v, index) => ( + <SelectItem key={index} value={v.value}> + {v.label} + </SelectItem> + ))} + </SelectContent> + </Select> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 4) companyId => domain이 partners인 경우만 노출 */} + {selectedDomain === "partners" && ( + <FormField + control={form.control} + name="companyId" + render={({ field }) => { + // 현재 선택된 회사 ID (number) → 문자열 + const valueString = field.value ? String(field.value) : "" + + + // 현재 선택된 회사 + const selectedCompany = companies.find( + (c) => String(c.id) === valueString + ) + + const selectedCompanyLabel = selectedCompany && `${selectedCompany.name} ${selectedCompany.taxID}` + + const [popoverOpen, setPopoverOpen] = React.useState(false) + + + return ( + <FormItem> + <FormLabel>Company</FormLabel> + <FormControl> + <Popover + open={popoverOpen} + onOpenChange={setPopoverOpen} + modal={true} + > + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={popoverOpen} + className="w-full justify-between" + > + {selectedCompany + ? `${selectedCompany.name} ${selectedCompany.taxID}` + : "Select company..."} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + + <PopoverContent className="w-full p-0"> + <Command> + <CommandInput + placeholder="Search company..." + className="h-9" + + /> + <CommandList> + <CommandEmpty>No company found.</CommandEmpty> + <CommandGroup> + {companies.map((comp) => { + // string(comp.id) + const compIdStr = String(comp.id) + const label = `${comp.name}${comp.taxID}` + const label2 = `${comp.name} ${comp.taxID}` + return ( + <CommandItem + key={comp.id} + value={label2} + onSelect={() => { + // 회사 ID를 number로 + field.onChange(Number(comp.id)) + setPopoverOpen(false) + + }} + > + {label2} + <Check + className={cn( + "ml-auto h-4 w-4", + selectedCompanyLabel === label2 + ? "opacity-100" + : "opacity-0" + )} + /> + </CommandItem> + ) + })} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + </FormControl> + <FormMessage /> + </FormItem> + ) + }} + /> + )} + </div> + + {/* Footer */} + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => setOpen(false)} + disabled={isAddPending} + > + Cancel + </Button> + <Button + type="submit" + disabled={form.formState.isSubmitting || isAddPending} + > + {isAddPending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + Create + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/roles/table/assign-roles-sheet.tsx b/lib/roles/table/assign-roles-sheet.tsx new file mode 100644 index 00000000..11c6a1ff --- /dev/null +++ b/lib/roles/table/assign-roles-sheet.tsx @@ -0,0 +1,87 @@ +"use client" + +import * as React from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm } from "react-hook-form" +import { toast } from "sonner" + +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Button } from "@/components/ui/button" +import { Loader } from "lucide-react" +import { AssginedUserTable } from "../userTable/assignedUsers-table" +import { assignUsersToRole } from "@/lib/users/service" +import { RoleView } from "@/db/schema/users" + +export interface UpdateRoleSheetProps + extends React.ComponentPropsWithRef<typeof Sheet> { + role: RoleView | null + + // ★ 새로 추가: 테이블에 필요한 데이터 로딩 promise + assignedTablePromises: Promise<[ + { data: any[]; pageCount: number } + + ]> +} + +export function AssignRolesSheet({ role, assignedTablePromises, ...props }: UpdateRoleSheetProps) { + + const [isUpdatePending, startUpdateTransition] = React.useTransition() + const [selectedUserIds, setSelectedUserIds] = React.useState<number[]>([]) + + // 2) 자식에서 호출될 콜백 + function handleSelectedChange(ids: number[]) { + setSelectedUserIds(ids) + } + + async function handleAssign() { + startUpdateTransition(async () => { + if (!role) return + const { error } = await assignUsersToRole(role.id, selectedUserIds) + if (error) { + toast.error(error) + return + } + props.onOpenChange?.(false) + toast.success(`Assigned ${selectedUserIds.length} users!`) + }) + } + + return ( + <Sheet {...props}> + <SheetContent className="flex flex-col gap-6 sm:max-w-md"> + <SheetHeader className="text-left"> + <SheetTitle>"{role?.name}"에 유저를 할당하세요</SheetTitle> + <SheetDescription> + 현재 {role?.name}에는 {role?.user_count}명이 할당되어있습니다. 이 롤은 다음과 같습니다.<br/> {role?.description} + </SheetDescription> + </SheetHeader> + + <AssginedUserTable promises={assignedTablePromises} onSelectedChange={handleSelectedChange} /> + + <SheetFooter className="gap-2 pt-2 sm:space-x-0"> + <SheetClose asChild> + <Button type="button" variant="outline"> + Cancel + </Button> + </SheetClose> + + {/* <Button disabled={isUpdatePending} onClick={onSubmitAssignUsers}> */} + <Button disabled={isUpdatePending} onClick={handleAssign}> + {isUpdatePending && ( + <Loader className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" /> + )} + Assign + </Button> + </SheetFooter> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/lib/roles/table/delete-roles-dialog.tsx b/lib/roles/table/delete-roles-dialog.tsx new file mode 100644 index 00000000..269bc7c3 --- /dev/null +++ b/lib/roles/table/delete-roles-dialog.tsx @@ -0,0 +1,149 @@ +"use client" + +import * as React from "react" +import { type Row } from "@tanstack/react-table" +import { Loader, Trash } from "lucide-react" +import { toast } from "sonner" + +import { useMediaQuery } from "@/hooks/use-media-query" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer" + +import { removeRoles } from "../services" +import { RoleView } from "@/db/schema/users" + +interface DeleteRolesDialogProps + extends React.ComponentPropsWithoutRef<typeof Dialog> { + roles: Row<RoleView>["original"][] + showTrigger?: boolean + onSuccess?: () => void +} + +export function DeleteRolesDialog({ + roles, + showTrigger = true, + onSuccess, + ...props +}: DeleteRolesDialogProps) { + const [isDeletePending, startDeleteTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + + function onDelete() { + startDeleteTransition(async () => { + const { error } = await removeRoles({ + ids: roles.map((role) => Number(role.id)), + }) + + if (error) { + toast.error(error) + return + } + + props.onOpenChange?.(false) + toast.success("Users deleted") + onSuccess?.() + }) + } + + if (isDesktop) { + return ( + <Dialog {...props}> + {showTrigger ? ( + <DialogTrigger asChild> + <Button variant="outline" size="sm"> + <Trash className="mr-2 size-4" aria-hidden="true" /> + Delete ({roles.length}) + </Button> + </DialogTrigger> + ) : null} + <DialogContent> + <DialogHeader> + <DialogTitle>Are you absolutely sure?</DialogTitle> + <DialogDescription> + This action cannot be undone. This will permanently delete your{" "} + <span className="font-medium">{roles.length}</span> + {roles.length === 1 ? " role" : " roles"} from our servers. + </DialogDescription> + </DialogHeader> + <DialogFooter className="gap-2 sm:space-x-0"> + <DialogClose asChild> + <Button variant="outline">Cancel</Button> + </DialogClose> + <Button + aria-label="Delete selected rows" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending} + > + {isDeletePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + Delete + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) + } + + return ( + <Drawer {...props}> + {showTrigger ? ( + <DrawerTrigger asChild> + <Button variant="outline" size="sm"> + <Trash className="mr-2 size-4" aria-hidden="true" /> + Delete ({roles.length}) + </Button> + </DrawerTrigger> + ) : null} + <DrawerContent> + <DrawerHeader> + <DrawerTitle>Are you absolutely sure?</DrawerTitle> + <DrawerDescription> + This action cannot be undone. This will permanently delete your{" "} + <span className="font-medium">{roles.length}</span> + {roles.length === 1 ? " role" : " roles"} from our servers. + </DrawerDescription> + </DrawerHeader> + <DrawerFooter className="gap-2 sm:space-x-0"> + <DrawerClose asChild> + <Button variant="outline">Cancel</Button> + </DrawerClose> + <Button + aria-label="Delete selected rows" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending} + > + {isDeletePending && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + Delete + </Button> + </DrawerFooter> + </DrawerContent> + </Drawer> + ) +} diff --git a/lib/roles/table/role-table-toolbar-actions.tsx b/lib/roles/table/role-table-toolbar-actions.tsx new file mode 100644 index 00000000..66e279d6 --- /dev/null +++ b/lib/roles/table/role-table-toolbar-actions.tsx @@ -0,0 +1,101 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Download, Upload } from "lucide-react" +import { toast } from "sonner" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" + +// 삭제, 추가 다이얼로그 + +// 만약 서버 액션이나 API 라우트를 이용해 업로드 처리한다면 import +import { importTasksExcel } from "@/lib/tasks/service" // 예시 +import { AddRoleDialog } from "./add-role-dialog" +import { DeleteRolesDialog } from "./delete-roles-dialog" +import { RoleView } from "@/db/schema/users" + +interface RoleTableToolbarActionsProps { + table: Table<RoleView> +} + +export function RoleTableToolbarActions({ table }: RoleTableToolbarActionsProps) { + // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식 + const fileInputRef = React.useRef<HTMLInputElement>(null) + + // 파일이 선택되었을 때 처리 + async function onFileChange(event: React.ChangeEvent<HTMLInputElement>) { + const file = event.target.files?.[0] + if (!file) return + + // 파일 초기화 (동일 파일 재업로드 시에도 onChange가 트리거되도록) + event.target.value = "" + + // 서버 액션 or API 호출 + try { + // 예: 서버 액션 호출 + const { errorFile, errorMessage } = await importTasksExcel(file) + + if (errorMessage) { + toast.error(errorMessage) + } + if (errorFile) { + // 에러 엑셀을 다운로드 + const url = URL.createObjectURL(errorFile) + const link = document.createElement("a") + link.href = url + link.download = "errors.xlsx" + link.click() + URL.revokeObjectURL(url) + } else { + // 성공 + toast.success("Import success") + // 필요 시 revalidateTag("tasks") 등 + } + + } catch (err) { + toast.error("파일 업로드 중 오류가 발생했습니다.") + + } + } + + function handleImportClick() { + // 숨겨진 <input type="file" /> 요소를 클릭 + fileInputRef.current?.click() + } + + return ( + <div className="flex items-center gap-2"> + + {table.getFilteredSelectedRowModel().rows.length > 0 ? ( + <DeleteRolesDialog + roles={table + .getFilteredSelectedRowModel() + .rows.map((row) => row.original)} + onSuccess={() => table.toggleAllRowsSelected(false)} + /> + ) : null} + + <AddRoleDialog /> + + + + {/** 4) Export 버튼 */} + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(table, { + filename: "roles", + excludeColumns: ["select", "actions"], + }) + } + className="gap-2" + > + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Export</span> + </Button> + </div> + ) +}
\ No newline at end of file diff --git a/lib/roles/table/roles-table-columns.tsx b/lib/roles/table/roles-table-columns.tsx new file mode 100644 index 00000000..3a491585 --- /dev/null +++ b/lib/roles/table/roles-table-columns.tsx @@ -0,0 +1,223 @@ +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { Ellipsis } from "lucide-react" + +import { formatDate } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header" +import { UserWithCompanyAndRoles } from "@/types/user" +import { getErrorMessage } from "@/lib/handle-error" + +import { modifiUser } from "@/lib/admin-users/service" +import { toast } from "sonner" + +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { MultiSelect } from "@/components/ui/multi-select" +import { roleColumnsConfig } from "@/config/roleColumnsConfig" +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" +import { RoleView } from "@/db/schema/users" + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<RoleView> | null>> +} + +/** + * tanstack table 컬럼 정의 (중첩 헤더 버전) + */ +export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<RoleView>[] { + // ---------------------------------------------------------------- + // 1) select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef<RoleView> = { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + size:40, + enableSorting: false, + enableHiding: false, + } + + // ---------------------------------------------------------------- + // 2) actions 컬럼 (Dropdown 메뉴) + // ---------------------------------------------------------------- + const actionsColumn: ColumnDef<RoleView> = { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-8 p-0 data-[state=open]:bg-muted" + > + <Ellipsis className="size-4" aria-hidden="true" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-40"> + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "update" })} + > + Edit + </DropdownMenuItem> + + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "user" })} + > + User Assignment + </DropdownMenuItem> + + <DropdownMenuSeparator /> + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "delete" })} + > + Delete + <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ) + }, + size: 40, + } + + // ---------------------------------------------------------------- + // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 + // ---------------------------------------------------------------- + // 3-1) groupMap: { [groupName]: ColumnDef<User>[] } + const groupMap: Record<string, ColumnDef<RoleView>[]> = {} + + roleColumnsConfig.forEach((cfg) => { + // 만약 group가 없으면 "_noGroup" 처리 + const groupName = cfg.group || "_noGroup" + + if (!groupMap[groupName]) { + groupMap[groupName] = [] + } + + // child column 정의 + const childCol: ColumnDef<RoleView> = { + accessorKey: cfg.id, + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title={cfg.label} /> + ), + meta: { + excelHeader: cfg.excelHeader, + group: cfg.group, + type: cfg.type, + }, + cell: ({ row, cell }) => { + + if (cfg.id === "created_at") { + const dateVal = cell.getValue() as Date + return formatDate(dateVal) + } + + if (cfg.id === "domain") { + const dateVal = cell.getValue() as string + return ( + <div className="flex w-[6.25rem] items-center"> + {dateVal === "evcp"?"삼성중공업":"협력업체"} + </div>) + } + + if (cfg.id === "user_count") { + const dateVal = cell.getValue() as number + return ( + <div className="flex w-[3.25rem] items-center"> + {dateVal} + </div>) + } + + if (cfg.id === "description") { + const val = cell.getValue() as string; + return ( + <Tooltip> + <TooltipTrigger asChild> + <span className="line-clamp-2 w-[400px]"> + {val} + </span> + </TooltipTrigger> + <TooltipContent> + {val} + </TooltipContent> + </Tooltip> + ); + } + + return row.getValue(cfg.id) ?? "" + }, + } + + groupMap[groupName].push(childCol) + }) + + // ---------------------------------------------------------------- + // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기 + // ---------------------------------------------------------------- + const nestedColumns: ColumnDef<RoleView>[] = [] + + // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함 + // 여기서는 그냥 Object.entries 순서 + Object.entries(groupMap).forEach(([groupName, colDefs]) => { + if (groupName === "_noGroup") { + // 그룹 없음 → 그냥 최상위 레벨 컬럼 + nestedColumns.push(...colDefs) + } else { + // 상위 컬럼 + nestedColumns.push({ + id: groupName, + header: groupName, // "Basic Info", "Metadata" 등 + columns: colDefs, + }) + } + }) + + // ---------------------------------------------------------------- + // 4) 최종 컬럼 배열: select, nestedColumns, actions + // ---------------------------------------------------------------- + return [ + selectColumn, + ...nestedColumns, + actionsColumn, + ] +}
\ No newline at end of file diff --git a/lib/roles/table/roles-table.tsx b/lib/roles/table/roles-table.tsx new file mode 100644 index 00000000..cd7c2a3b --- /dev/null +++ b/lib/roles/table/roles-table.tsx @@ -0,0 +1,169 @@ +"use client" + +import * as React from "react" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" + +import { toSentenceCase } from "@/lib/utils" +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import { DataTableToolbar } from "@/components/data-table/data-table-toolbar" + + +import { getRolesWithCount } from "@/lib/roles/services" +import { getColumns } from "./roles-table-columns" +import { RoleTableToolbarActions } from "./role-table-toolbar-actions" +import { UpdateRolesSheet } from "./update-roles-sheet" +import { AssignRolesSheet } from "./assign-roles-sheet" +import { getUsersAll } from "@/lib/users/service" +import { DeleteRolesDialog } from "./delete-roles-dialog" +import { RoleView } from "@/db/schema/users" + + +interface RolesTableProps { + promises: Promise< + [ + Awaited<ReturnType<typeof getRolesWithCount>>, + ] + > + promises2: Promise< + [ + Awaited<ReturnType<typeof getUsersAll>>, + ] +> +} + +export function RolesTable({ promises ,promises2 }: RolesTableProps) { + + const [{ data, pageCount }] = + React.use(promises) + + const [rowAction, setRowAction] = + React.useState<DataTableRowAction<RoleView> | null>(null) + + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + + /** + * This component can render either a faceted filter or a search filter based on the `options` prop. + * + * @prop options - An array of objects, each representing a filter option. If provided, a faceted filter is rendered. If not, a search filter is rendered. + * + * Each `option` object has the following properties: + * @prop {string} label - The label for the filter option. + * @prop {string} value - The value for the filter option. + * @prop {React.ReactNode} [icon] - An optional icon to display next to the label. + * @prop {boolean} [withCount] - An optional boolean to display the count of the filter option. + */ + const filterFields: DataTableFilterField<RoleView>[] = [ + { + id: "name", + label: "Role Name", + placeholder: "Filter role name...", + }, + + ] + + /** + * Advanced filter fields for the data table. + * These fields provide more complex filtering options compared to the regular filterFields. + * + * Key differences from regular filterFields: + * 1. More field types: Includes 'text', 'multi-select', 'date', and 'boolean'. + * 2. Enhanced flexibility: Allows for more precise and varied filtering options. + * 3. Used with DataTableAdvancedToolbar: Enables a more sophisticated filtering UI. + * 4. Date and boolean types: Adds support for filtering by date ranges and boolean values. + */ + const advancedFilterFields: DataTableAdvancedFilterField<RoleView>[] = [ + { + id: "name", + label: "Role Name", + type: "text", + }, + + { + id: "domain", + label: "룰 도메인", + type: "text", + }, + + { + id: "company_name", + label: "회사명", + type: "text", + }, + + { + id: "created_at", + label: "Created at", + type: "date", + }, + ] + + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "created_at", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => `${originalRow.id}`, + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + <DataTable + table={table} + + > + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <RoleTableToolbarActions table={table} /> + </DataTableAdvancedToolbar> + + </DataTable> + + <UpdateRolesSheet + open={rowAction?.type === "update"} + onOpenChange={() => setRowAction(null)} + role={rowAction?.row.original ?? null} + /> + + <AssignRolesSheet + open={rowAction?.type === "user"} + onOpenChange={() => setRowAction(null)} + role={rowAction?.row.original ?? null} + assignedTablePromises={promises2} + /> + + <DeleteRolesDialog + open={rowAction?.type === "delete"} + onOpenChange={() => setRowAction(null)} + roles={rowAction?.row.original ? [rowAction?.row.original] : []} + showTrigger={false} + onSuccess={() => rowAction?.row.toggleSelected(false)} + /> + + + + + </> + ) +} diff --git a/lib/roles/table/update-roles-sheet.tsx b/lib/roles/table/update-roles-sheet.tsx new file mode 100644 index 00000000..cbe20352 --- /dev/null +++ b/lib/roles/table/update-roles-sheet.tsx @@ -0,0 +1,331 @@ +"use client" + +import * as React from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm } from "react-hook-form" +import { toast } from "sonner" +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { + Select, + SelectTrigger, + SelectContent, + SelectItem, + SelectValue, + SelectGroup, +} from "@/components/ui/select" +// import your MultiSelect or other role selection +import { RoleView, userRoles, type UserView } from "@/db/schema/users" +import { getAllCompanies, modifiUser } from "@/lib/admin-users/service" +import { modifiRole } from "../services" +import { updateRoleSchema, UpdateRoleSchema } from "../validations" +import { Check, ChevronsUpDown, Loader } from "lucide-react" +import { Textarea } from "@/components/ui/textarea" +import { + Popover, + PopoverTrigger, + PopoverContent, +} from "@/components/ui/popover" +import { + Command, + CommandInput, + CommandList, + CommandGroup, + CommandItem, + CommandEmpty, +} from "@/components/ui/command" +import { Company } from "@/db/schema/companies" +import { cn } from "@/lib/utils" + +export interface UpdateRoleSheetProps + extends React.ComponentPropsWithRef<typeof Sheet> { + role: RoleView | null +} + +const domainOptions = [ + { value: "partners", label: "협력업체" }, + { value: "evcp", label: "삼성중공업" }, +] + + + +export function UpdateRolesSheet({ role, ...props }: UpdateRoleSheetProps) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + const [companies, setCompanies] = React.useState<Company[]>([]) // 회사 목록 + + React.useEffect(() => { + getAllCompanies().then((res) => { + setCompanies(res) + }) + }, []) + + + // 1) RHF 설정 + const form = useForm<UpdateRoleSchema>({ + resolver: zodResolver(updateRoleSchema), + defaultValues: { + name: role?.name ?? "", + description: role?.description ?? "", + domain: (role?.domain === "evcp" || role?.domain === "partners") + ? role?.domain + : undefined, + }, + }) + + // 2) user prop 바뀔 때마다 form.reset + React.useEffect(() => { + if (role) { + form.reset({ + name: role.name, + description: role.description, + domain: role.domain as "evcp" | "partners" | undefined, + }) + } + }, [role, form]) + + const selectedDomain = form.watch("domain") + + + // 3) onSubmit + async function onSubmit(input: UpdateRoleSchema) { + startUpdateTransition(async () => { + if (!role) return + + const { error } = await modifiRole({ + id: role.id, // user.userId + ...input, + }) + + if (error) { + toast.error(error) + return + } + + // 성공 시 + form.reset() + props.onOpenChange?.(false) + toast.success("User updated successfully!") + }) + } + + return ( + <Sheet {...props}> + <SheetContent className="flex flex-col gap-6 sm:max-w-md"> + <SheetHeader className="text-left"> + <SheetTitle>Update user</SheetTitle> + <SheetDescription> + Update the user details and save the changes + </SheetDescription> + </SheetHeader> + + {/* 4) RHF Form */} + <Form {...form}> + <form + onSubmit={form.handleSubmit(onSubmit)} + className="flex flex-col gap-4" + > + {/* name */} + <FormField + control={form.control} + name="name" + render={({ field }) => ( + <FormItem> + <FormLabel>Role Name</FormLabel> + <FormControl> + <Input + placeholder="e.g. admin" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="description" + render={({ field }) => ( + <FormItem> + <FormLabel>Role Description</FormLabel> + <FormControl> + <Textarea + placeholder="Describe role" + className="resize-none" + {...field} + /> + </FormControl> + {/* <FormDescription> + You can <span>@mention</span> other users and organizations to + link to them. + </FormDescription> */} + <FormMessage /> + </FormItem> + )} + /> + + + {/* language Select */} + <FormField + control={form.control} + name="domain" + render={({ field }) => ( + <FormItem> + <FormLabel>Domain</FormLabel> + <FormControl> + <Select + onValueChange={field.onChange} + // 'value'로 현재 값 연결. defaultValue 대신 Controlled 컴포넌트로 + value={field.value} + > + <SelectTrigger> + <SelectValue placeholder="Select Domain" /> + </SelectTrigger> + <SelectContent> + {domainOptions.map((v, index) => ( + <SelectItem key={index} value={v.value}> + {v.label} + </SelectItem> + ))} + </SelectContent> + </Select> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {selectedDomain === "partners" && ( + <FormField + control={form.control} + name="company_id" + render={({ field }) => { + // 현재 선택된 회사 ID (number) → 문자열 + const valueString = field.value ? String(field.value) : "" + + + // 현재 선택된 회사 + const selectedCompany = companies.find( + (c) => String(c.id) === valueString + ) + + const selectedCompanyLabel = selectedCompany && `${selectedCompany.name} ${selectedCompany.taxID}` + + const [popoverOpen, setPopoverOpen] = React.useState(false) + + + return ( + <FormItem> + <FormLabel>Company</FormLabel> + <FormControl> + <Popover + open={popoverOpen} + onOpenChange={setPopoverOpen} + modal={true} + > + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={popoverOpen} + className="w-full justify-between" + > + {selectedCompany + ? `${selectedCompany.name} ${selectedCompany.taxID}` + : "Select company..."} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + + <PopoverContent className="w-full p-0" side="bottom" > + <Command> + <CommandInput + placeholder="Search company..." + className="h-9" + + /> + <CommandList> + <CommandEmpty>No company found.</CommandEmpty> + <CommandGroup> + {companies.map((comp) => { + // string(comp.id) + const compIdStr = String(comp.id) + const label = `${comp.name}${comp.taxID}` + const label2 = `${comp.name} ${comp.taxID}` + return ( + <CommandItem + key={comp.id} + value={label2} + onSelect={() => { + // 회사 ID를 number로 + field.onChange(Number(comp.id)) + setPopoverOpen(false) + + }} + > + {label2} + <Check + className={cn( + "ml-auto h-4 w-4", + selectedCompanyLabel === label2 + ? "opacity-100" + : "opacity-0" + )} + /> + </CommandItem> + ) + })} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + </FormControl> + <FormMessage /> + </FormItem> + ) + }} + /> + )} + + {/* 5) Footer: Cancel, Save */} + <SheetFooter className="gap-2 pt-2 sm:space-x-0"> + <SheetClose asChild> + <Button type="button" variant="outline"> + Cancel + </Button> + </SheetClose> + + <Button type="submit" disabled={isUpdatePending}> + {isUpdatePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + Save + </Button> + </SheetFooter> + </form> + </Form> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/lib/roles/userTable/assginedUsers-table-columns.tsx b/lib/roles/userTable/assginedUsers-table-columns.tsx new file mode 100644 index 00000000..b317a465 --- /dev/null +++ b/lib/roles/userTable/assginedUsers-table-columns.tsx @@ -0,0 +1,164 @@ +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { Ellipsis } from "lucide-react" +import { userRoles, type UserView } from "@/db/schema/users" + +import { formatDate } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header" +import { UserWithCompanyAndRoles } from "@/types/user" +import { getErrorMessage } from "@/lib/handle-error" + +import { modifiUser } from "@/lib/admin-users/service" +import { toast } from "sonner" + +import { euserColumnsConfig } from "@/config/euserColumnsConfig" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { MultiSelect } from "@/components/ui/multi-select" + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<UserView> | null>> +} + +/** + * tanstack table 컬럼 정의 (중첩 헤더 버전) + */ +export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<UserView>[] { + // ---------------------------------------------------------------- + // 1) select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef<UserView> = { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + size:40, + enableSorting: false, + enableHiding: false, + } + + // ---------------------------------------------------------------- + // 2) actions 컬럼 (Dropdown 메뉴) + // ---------------------------------------------------------------- + + + // ---------------------------------------------------------------- + // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 + // ---------------------------------------------------------------- + // 3-1) groupMap: { [groupName]: ColumnDef<User>[] } + const groupMap: Record<string, ColumnDef<UserView>[]> = {} + + euserColumnsConfig.forEach((cfg) => { + // 만약 group가 없으면 "_noGroup" 처리 + const groupName = cfg.group || "_noGroup" + + if (!groupMap[groupName]) { + groupMap[groupName] = [] + } + + // child column 정의 + const childCol: ColumnDef<UserView> = { + accessorKey: cfg.id, + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title={cfg.label} /> + ), + meta: { + excelHeader: cfg.excelHeader, + group: cfg.group, + type: cfg.type, + }, + cell: ({ row, cell }) => { + + if (cfg.id === "created_at") { + const dateVal = cell.getValue() as Date + return formatDate(dateVal) + } + + if (cfg.id === "roles") { + const roleValues = row.original.roles; + return ( + <div className="flex flex-wrap gap-1"> + {roleValues.map((v) => ( + v === null?"": + + <Badge key={v} variant="outline"> + {v} + </Badge> + + ))} + </div> + ); + } + + return row.getValue(cfg.id) ?? "" + }, + } + + groupMap[groupName].push(childCol) + }) + + // ---------------------------------------------------------------- + // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기 + // ---------------------------------------------------------------- + const nestedColumns: ColumnDef<UserView>[] = [] + + // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함 + // 여기서는 그냥 Object.entries 순서 + Object.entries(groupMap).forEach(([groupName, colDefs]) => { + if (groupName === "_noGroup") { + // 그룹 없음 → 그냥 최상위 레벨 컬럼 + nestedColumns.push(...colDefs) + } else { + // 상위 컬럼 + nestedColumns.push({ + id: groupName, + header: groupName, // "Basic Info", "Metadata" 등 + columns: colDefs, + }) + } + }) + + // ---------------------------------------------------------------- + // 4) 최종 컬럼 배열: select, nestedColumns, actions + // ---------------------------------------------------------------- + return [ + selectColumn, + ...nestedColumns, + + ] +}
\ No newline at end of file diff --git a/lib/roles/userTable/assignedUsers-table.tsx b/lib/roles/userTable/assignedUsers-table.tsx new file mode 100644 index 00000000..5ac52f13 --- /dev/null +++ b/lib/roles/userTable/assignedUsers-table.tsx @@ -0,0 +1,159 @@ +"use client" + +import * as React from "react" +import { userRoles , type UserView} from "@/db/schema/users" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" + +import { toSentenceCase } from "@/lib/utils" +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import { DataTableToolbar } from "@/components/data-table/data-table-toolbar" + +import type { + getAllRoles, getUsersAll, getUsersEVCP +} from "@/lib//users/service" +import { getColumns } from "./assginedUsers-table-columns" + + + +interface UsersTableProps { + promises: Promise< + [ + Awaited<ReturnType<typeof getUsersAll>> + + ] + > + onSelectedChange:any +} + +export function AssginedUserTable({ promises ,onSelectedChange}: UsersTableProps) { + + const [{ data, pageCount }] = + React.use(promises) + + + const [rowAction, setRowAction] = + React.useState<DataTableRowAction<UserView> | null>(null) + + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + + + /** + * This component can render either a faceted filter or a search filter based on the `options` prop. + * + * @prop options - An array of objects, each representing a filter option. If provided, a faceted filter is rendered. If not, a search filter is rendered. + * + * Each `option` object has the following properties: + * @prop {string} label - The label for the filter option. + * @prop {string} value - The value for the filter option. + * @prop {React.ReactNode} [icon] - An optional icon to display next to the label. + * @prop {boolean} [withCount] - An optional boolean to display the count of the filter option. + */ + const filterFields: DataTableFilterField<UserView>[] = [ + { + id: "user_email", + label: "Email", + placeholder: "Filter email...", + }, + + ] + + /** + * Advanced filter fields for the data table. + * These fields provide more complex filtering options compared to the regular filterFields. + * + * Key differences from regular filterFields: + * 1. More field types: Includes 'text', 'multi-select', 'date', and 'boolean'. + * 2. Enhanced flexibility: Allows for more precise and varied filtering options. + * 3. Used with DataTableAdvancedToolbar: Enables a more sophisticated filtering UI. + * 4. Date and boolean types: Adds support for filtering by date ranges and boolean values. + */ + const advancedFilterFields: DataTableAdvancedFilterField<UserView>[] = [ + { + id: "user_name", + label: "User Name", + type: "text", + }, + { + id: "user_email", + label: "Email", + type: "text", + }, + + + { + id: "created_at", + label: "Created at", + type: "date", + }, + ] + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "created_at", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => `${originalRow.user_id}`, + shallow: false, + clearOnDefault: true, + }) + + const rowSelection = table.getState().rowSelection + + function shallowEqual(arrA: number[], arrB: number[]): boolean { + if (arrA.length !== arrB.length) return false + for (let i = 0; i < arrA.length; i++) { + if (arrA[i] !== arrB[i]) return false + } + return true + } + const previousUserIdsRef = React.useRef<number[]>([]) + + React.useEffect(() => { + // 선택 상태가 바뀌었을 때만 실행 + if (!onSelectedChange) return + + const rows = table.getSelectedRowModel().rows + const newUserIds = rows.map((r) => r.original.user_id) + + // 이전/새 userIds 비교 + if (!shallowEqual(previousUserIdsRef.current, newUserIds)) { + previousUserIdsRef.current = newUserIds + onSelectedChange(newUserIds) + } + }, [rowSelection, onSelectedChange]) + + return ( + <> + <DataTable + table={table} + + > + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + </DataTableAdvancedToolbar> + + </DataTable> + + + </> + ) +} diff --git a/lib/roles/validations.ts b/lib/roles/validations.ts new file mode 100644 index 00000000..10cfe33b --- /dev/null +++ b/lib/roles/validations.ts @@ -0,0 +1,80 @@ +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, +} from "nuqs/server" +import * as z from "zod" +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" +import { RoleView, users } from "@/db/schema/users"; + +export const searchParamsCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault( + [] + ), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser<RoleView>().withDefault([ + { id: "created_at", desc: true }, + ]), + name: parseAsString.withDefault(""), + // advanced filter + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), + + }) + + export const createRoleSchema = z.object({ + name: z.string().min(1), + description: z.string().min(1), + companyId:z + .number() + .int() + .positive() + .nullish(), // number | nullish + domain: z.enum(users.domain.enumValues), // "evcp" | "partners" + }); + + export const createRoleAssignmentSchema = z.object({ + evcpRoles:z.array(z.string()), + + }); + + + + export const updateRoleSchema = z.object({ + name: z.string().min(1), + description: z.string().min(1), + domain: z.enum(users.domain.enumValues), // "evcp" | "partners" + company_id: z + .number() + .int() + .positive() + .nullish(), // number | nullish + }).superRefine((data, ctx) => { + // domain이 partners 이면 companyId는 필수 + if (data.domain === "partners" && !data.company_id) { + ctx.addIssue({ + code: "custom", + path: ["company_id"], + message: "협력업체(domain=partners)일 경우 companyId는 필수입니다.", + }) + } + + // domain이 evcp 이면 companyId는 null이어야 한다면(정책상) + if (data.domain === "evcp" && data.company_id) { + ctx.addIssue({ + code: "custom", + path: ["company_id"], + message: "domain=evcp이면 companyId를 입력할 수 없습니다.", + }) + } + }) + +// TypeScript에서 사용할 타입 +export type GetRolesSchema = Awaited<ReturnType<typeof searchParamsCache.parse>> +export type CreateRoleSchema = z.infer<typeof createRoleSchema> +export type UpdateRoleSchema = z.infer<typeof updateRoleSchema> +export type CreateRoleAssignmentSchema = z.infer<typeof createRoleAssignmentSchema>
\ No newline at end of file diff --git a/lib/storage.ts b/lib/storage.ts new file mode 100644 index 00000000..ead937aa --- /dev/null +++ b/lib/storage.ts @@ -0,0 +1,44 @@ +import fs from "fs/promises" +import path from "path" +import crypto from "crypto" + +/** + * 주어진 File을 해시된 파일명으로 로컬에 저장하고, + * 저장된 파일의 메타데이터를 반환하는 공용 함수 + */ +export async function saveDocument( + file: File, + directory: string = "./public" +) { + // 확장자 추출 + const originalName = file.name + const ext = path.extname(originalName) || "" + + // 해시 파일명 생성 + const randomHash = crypto.randomBytes(20).toString("hex") + const hashedFileName = randomHash + ext + + // 파일 저장 + await storeFile(file, hashedFileName, directory) + + // 필요한 메타데이터 반환 (원본 이름, 해시 파일명 등) + return { + originalName, + hashedFileName, + ext, + // 필요하면 file.size, file.type 등도 포함 가능 + } +} + +/** + * 실제 파일 쓰기 (로컬이든, S3든 자유롭게 교체 가능) + */ +async function storeFile(file: File, hashedFileName: string, directory: string) { + const arrayBuffer = await file.arrayBuffer() + const buffer = Buffer.from(arrayBuffer) + + const filePath = path.join(directory, hashedFileName) + + // 만약 기존 파일에 추가가 아니라 새 파일 생성이라면 writeFile 사용 + await fs.writeFile(filePath, buffer) +}
\ No newline at end of file diff --git a/lib/tag-numbering/repository.ts b/lib/tag-numbering/repository.ts new file mode 100644 index 00000000..6ebf84db --- /dev/null +++ b/lib/tag-numbering/repository.ts @@ -0,0 +1,45 @@ +import db from "@/db/db"; +import { viewTagSubfields } from "@/db/schema/vendorData"; +import { + eq, + inArray, + not, + asc, + desc, + and, + ilike, + gte, + lte, + count, + gt, +} from "drizzle-orm"; +import { PgTransaction } from "drizzle-orm/pg-core"; + +export async function selectTagNumbering( + tx: PgTransaction<any, any, any>, + params: { + where?: any; // drizzle-orm의 조건식 (and, eq...) 등 + orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[]; + offset?: number; + limit?: number; + } + ) { + const { where, orderBy, offset = 0, limit = 10 } = params; + + return tx + .select() + .from(viewTagSubfields) + .where(where) + .orderBy(...(orderBy ?? [])) + .offset(offset) + .limit(limit); + } + /** 총 개수 count */ + export async function countTagNumbering( + tx: PgTransaction<any, any, any>, + where?: any + ) { + const res = await tx.select({ count: count() }).from(viewTagSubfields).where(where); + return res[0]?.count ?? 0; + } +
\ No newline at end of file diff --git a/lib/tag-numbering/service.ts b/lib/tag-numbering/service.ts new file mode 100644 index 00000000..9b1c1172 --- /dev/null +++ b/lib/tag-numbering/service.ts @@ -0,0 +1,123 @@ +"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택) + +import { revalidateTag, unstable_noStore } from "next/cache"; +import db from "@/db/db"; +import { unstable_cache } from "@/lib/unstable-cache"; +import { GetTagNumberigSchema } from "./validation"; +import { filterColumns } from "@/lib/filter-columns"; +import { TagSubfieldOption, tagSubfieldOptions, ViewTagSubfields, viewTagSubfields } from "@/db/schema/vendorData"; +import { asc, desc, ilike, inArray, and, gte, lte, not, or, eq } from "drizzle-orm"; +import { countTagNumbering, selectTagNumbering } from "./repository"; + +export async function getTagNumbering(input: GetTagNumberigSchema) { + + return unstable_cache( + async () => { + try { + const offset = (input.page - 1) * input.perPage; + + // const advancedTable = input.flags.includes("advancedTable"); + const advancedTable = true; + + // advancedTable 모드면 filterColumns()로 where 절 구성 + const advancedWhere = filterColumns({ + table: viewTagSubfields, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + + let globalWhere + if (input.search) { + const s = `%${input.search}%` + globalWhere = or(ilike(viewTagSubfields.tagTypeCode, s), ilike(viewTagSubfields.tagTypeDescription, s) + , ilike(viewTagSubfields.attributesId, s) , ilike(viewTagSubfields.attributesDescription, s), ilike(viewTagSubfields.expression, s) + ) + // 필요시 여러 칼럼 OR조건 (status, priority, etc) + } + + const finalWhere = and( + // advancedWhere or your existing conditions + advancedWhere, + globalWhere // and()함수로 결합 or or() 등으로 결합 + ) + + + // 아니면 ilike, inArray, gte 등으로 where 절 구성 + const where = finalWhere + + + const orderBy = + input.sort.length > 0 + ? input.sort.map((item) => + item.desc ? desc(viewTagSubfields[item.id]) : asc(viewTagSubfields[item.id]) + ) + : [asc(viewTagSubfields.createdAt)]; + + // 트랜잭션 내부에서 Repository 호출 + const { data, total } = await db.transaction(async (tx) => { + const data = await selectTagNumbering(tx, { + where, + orderBy, + offset, + limit: input.perPage, + }); + + const total = await countTagNumbering(tx, where); + return { data, total }; + }); + + const pageCount = Math.ceil(total / input.perPage); + + return { data, pageCount }; + } catch (err) { + // 에러 발생 시 디폴트 + return { data: [], pageCount: 0 }; + } + }, + [JSON.stringify(input)], // 캐싱 키 + { + revalidate: 3600, + tags: ["tag-numbering"], // revalidateTag("items") 호출 시 무효화 + } + )(); + } + + + + export const fetchTagSubfieldOptions = (async (attributesId: string): Promise<TagSubfieldOption[]> => { + try { + // (A) findMany -> 스키마 제네릭 누락 에러 발생 → 대신 select().from().where() 사용 + const rows = await db + .select() + .from(tagSubfieldOptions) + .where(eq(tagSubfieldOptions.attributesId, attributesId)) + .orderBy(asc(tagSubfieldOptions.code)) + + // rows는 TagSubfieldOption[] 형태 + return rows + } catch (error) { + console.error("Error fetching tag subfield options:", error) + return [] + } + }) + + export const getTagNumberingRules = (async (tagType: string): Promise<ViewTagSubfields[]> => { + try { + if (!tagType) { + return [] + } + + // 기존 findMany 대신 select().from().where() + orderBy + const rules = await db + .select() + .from(viewTagSubfields) + .where(eq(viewTagSubfields.tagTypeDescription, tagType)) + .orderBy(asc(viewTagSubfields.sortOrder)) + + return rules + } catch (error) { + console.error("Error fetching tag numbering rules:", error) + return [] + } + })
\ No newline at end of file diff --git a/lib/tag-numbering/table/feature-flags-provider.tsx b/lib/tag-numbering/table/feature-flags-provider.tsx new file mode 100644 index 00000000..81131894 --- /dev/null +++ b/lib/tag-numbering/table/feature-flags-provider.tsx @@ -0,0 +1,108 @@ +"use client" + +import * as React from "react" +import { useQueryState } from "nuqs" + +import { dataTableConfig, type DataTableConfig } from "@/config/data-table" +import { cn } from "@/lib/utils" +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" + +type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"] + +interface FeatureFlagsContextProps { + featureFlags: FeatureFlagValue[] + setFeatureFlags: (value: FeatureFlagValue[]) => void +} + +const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({ + featureFlags: [], + setFeatureFlags: () => {}, +}) + +export function useFeatureFlags() { + const context = React.useContext(FeatureFlagsContext) + if (!context) { + throw new Error( + "useFeatureFlags must be used within a FeatureFlagsProvider" + ) + } + return context +} + +interface FeatureFlagsProviderProps { + children: React.ReactNode +} + +export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) { + const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>( + "flags", + { + defaultValue: [], + parse: (value) => value.split(",") as FeatureFlagValue[], + serialize: (value) => value.join(","), + eq: (a, b) => + a.length === b.length && a.every((value, index) => value === b[index]), + clearOnDefault: true, + shallow: false, + } + ) + + return ( + <FeatureFlagsContext.Provider + value={{ + featureFlags, + setFeatureFlags: (value) => void setFeatureFlags(value), + }} + > + <div className="w-full overflow-x-auto"> + <ToggleGroup + type="multiple" + variant="outline" + size="sm" + value={featureFlags} + onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)} + className="w-fit gap-0" + > + {dataTableConfig.featureFlags.map((flag, index) => ( + <Tooltip key={flag.value}> + <ToggleGroupItem + value={flag.value} + className={cn( + "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90", + { + "rounded-l-sm border-r-0": index === 0, + "rounded-r-sm": + index === dataTableConfig.featureFlags.length - 1, + } + )} + asChild + > + <TooltipTrigger> + <flag.icon className="size-3.5 shrink-0" aria-hidden="true" /> + {flag.label} + </TooltipTrigger> + </ToggleGroupItem> + <TooltipContent + align="start" + side="bottom" + sideOffset={6} + className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground" + > + <div>{flag.tooltipTitle}</div> + <div className="text-xs text-muted-foreground"> + {flag.tooltipDescription} + </div> + </TooltipContent> + </Tooltip> + ))} + </ToggleGroup> + </div> + {children} + </FeatureFlagsContext.Provider> + ) +} diff --git a/lib/tag-numbering/table/meta-sheet.tsx b/lib/tag-numbering/table/meta-sheet.tsx new file mode 100644 index 00000000..4221837c --- /dev/null +++ b/lib/tag-numbering/table/meta-sheet.tsx @@ -0,0 +1,226 @@ +"use client" + +import * as React from "react" +import { useEffect, useState } from "react" +import { Copy } from "lucide-react" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle +} from "@/components/ui/sheet" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from "@/components/ui/table" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + CardFooter +} from "@/components/ui/card" +import { Separator } from "@/components/ui/separator" +import { ViewTagSubfields } from "@/db/schema/vendorData" +import { fetchTagSubfieldOptions } from "../service" + +interface TagOption { + id: number + attributesId: string + code: string + label: string + createdAt?: Date + updatedAt?: Date +} + +interface ViewTagOptionsProps { + open: boolean + onOpenChange: (open: boolean) => void + tagSubfield: ViewTagSubfields | null +} + +export function ViewTagOptions({ + open, + onOpenChange, + tagSubfield +}: ViewTagOptionsProps) { + const [options, setOptions] = useState<TagOption[]>([]) + const [loading, setLoading] = useState(false) + const [copied, setCopied] = useState<string | null>(null) + + // 옵션 데이터 가져오기 + useEffect(() => { + async function fetchOptions() { + if (!tagSubfield || !open) return + + setLoading(true) + try { + // 서버 액션 호출 - attributesId와 일치하는 모든 옵션 가져오기 + const optionsData = await fetchTagSubfieldOptions(tagSubfield.attributesId) + setOptions(optionsData || []) + } catch (error) { + console.error("Error fetching tag options:", error) + setOptions([]) + } finally { + setLoading(false) + } + } + + fetchOptions() + }, [tagSubfield, open]) + + // 코드 복사 기능 + const copyToClipboard = (text: string, type: string) => { + navigator.clipboard.writeText(text).then(() => { + setCopied(type) + setTimeout(() => setCopied(null), 2000) + }) + } + + if (!tagSubfield) return null + + return ( + <Sheet open={open} onOpenChange={onOpenChange}> + <SheetContent className="sm:max-w-xl md:max-w-3xl lg:max-w-4xl xl:max-w-5xl overflow-y-auto"> + + <SheetHeader className="mb-6"> + <SheetTitle className="text-xl flex items-center gap-2"> + Field Options + <Badge variant="outline" className="ml-2"> + {options.length} options + </Badge> + </SheetTitle> + <SheetDescription className="mb-4"> + Field information and available options + </SheetDescription> + + <div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4"> + <div className="space-y-2"> + <div className="flex items-center justify-between"> + <span className="text-sm font-medium">Attributes ID:</span> + <div className="flex items-center gap-1"> + <Badge variant="secondary"> + {tagSubfield.attributesId} + </Badge> + <Button + variant="ghost" + size="icon" + className="h-6 w-6" + onClick={() => copyToClipboard(tagSubfield.attributesId, 'attributesId')} + > + <Copy className="h-3 w-3" /> + </Button> + {copied === 'attributesId' && ( + <span className="text-xs text-green-600">Copied</span> + )} + </div> + </div> + <div className="flex items-center justify-between"> + <span className="text-sm font-medium">Tag Type:</span> + <Badge>{tagSubfield.tagTypeCode}</Badge> + </div> + </div> + <div className="space-y-2"> + <div className="flex items-center justify-between"> + <span className="text-sm font-medium">Description:</span> + <span className="text-sm">{tagSubfield.attributesDescription}</span> + </div> + <div className="flex items-center justify-between"> + <span className="text-sm font-medium">Expression:</span> + <code className="bg-muted px-2 py-1 rounded text-xs"> + {tagSubfield.expression || 'N/A'} + </code> + </div> + </div> + </div> + {tagSubfield.tagTypeDescription && ( + <div className="mt-4 text-sm bg-muted p-2 rounded"> + <span className="font-medium">Type Description: </span> + {tagSubfield.tagTypeDescription} + </div> + )} + + </SheetHeader> + + <Separator className="my-4" /> + + {loading ? ( + <div className="flex items-center justify-center h-40"> + <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div> + </div> + ) : options.length > 0 ? ( + <Card> + <CardHeader> + <CardTitle>Available Options</CardTitle> + <CardDescription> + All available options for field {tagSubfield.attributesId} + </CardDescription> + </CardHeader> + <CardContent> + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-24">Code</TableHead> + <TableHead>Label</TableHead> + <TableHead className="text-right">Actions</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {options.map((option) => ( + <TableRow key={option.id}> + <TableCell className="font-mono"> + {option.code} + </TableCell> + <TableCell>{option.label}</TableCell> + <TableCell className="text-right"> + <Button + variant="ghost" + size="sm" + className="h-8 w-8 p-0" + onClick={() => copyToClipboard(`${option.code} - ${option.label}`, `option-${option.id}`)} + > + <Copy className="h-4 w-4" /> + {copied === `option-${option.id}` && ( + <span className="absolute -top-2 -right-2 text-xs text-green-600 bg-white px-1 rounded-sm"> + Copied + </span> + )} + </Button> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </CardContent> + <CardFooter className="flex justify-between text-sm text-muted-foreground"> + <div> + {options.length} options found for {tagSubfield.attributesId} + </div> + {tagSubfield.delimiter && ( + <div> + Delimiter: <code className="bg-muted px-2 py-1 rounded text-xs">{tagSubfield.delimiter}</code> + </div> + )} + </CardFooter> + </Card> + ) : ( + <div className="text-center py-8"> + <div className="text-lg font-medium">No options found</div> + <p className="text-muted-foreground mt-2"> + This field ({tagSubfield.attributesId}) has no defined options. + </p> + </div> + )} + + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/lib/tag-numbering/table/tagNumbering-table-columns.tsx b/lib/tag-numbering/table/tagNumbering-table-columns.tsx new file mode 100644 index 00000000..6e9b8191 --- /dev/null +++ b/lib/tag-numbering/table/tagNumbering-table-columns.tsx @@ -0,0 +1,131 @@ +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { InfoIcon } from "lucide-react" + +import { formatDate } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" + +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { ViewTagSubfields } from "@/db/schema/vendorData" +import { tagNumberingColumnsConfig } from "@/config/tagNumberingColumnsConfig" + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<ViewTagSubfields> | null>> +} + +/** + * tanstack table 컬럼 정의 (중첩 헤더 버전) + */ +export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<ViewTagSubfields>[] { + // ---------------------------------------------------------------- + // 1) select 컬럼 (체크박스) + // ---------------------------------------------------------------- + + + // ---------------------------------------------------------------- + // 2) actions 컬럼 (단일 버튼 - Meta Info 바로 보기) + // ---------------------------------------------------------------- + const actionsColumn: ColumnDef<ViewTagSubfields> = { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + return ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="icon" + onClick={() => setRowAction({ row, type: "items" })} + > + <InfoIcon className="h-4 w-4" aria-hidden="true" /> + </Button> + </TooltipTrigger> + <TooltipContent> + View Option Info. + </TooltipContent> + </Tooltip> + </TooltipProvider> + ) + }, + size: 40, + } + + // ---------------------------------------------------------------- + // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 + // ---------------------------------------------------------------- + // 3-1) groupMap: { [groupName]: ColumnDef<ViewTagSubfields>[] } + const groupMap: Record<string, ColumnDef<ViewTagSubfields>[]> = {} + + tagNumberingColumnsConfig.forEach((cfg) => { + // 만약 group가 없으면 "_noGroup" 처리 + const groupName = cfg.group || "_noGroup" + + if (!groupMap[groupName]) { + groupMap[groupName] = [] + } + + // child column 정의 + const childCol: ColumnDef<ViewTagSubfields> = { + accessorKey: cfg.id, + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title={cfg.label} /> + ), + meta: { + excelHeader: cfg.excelHeader, + group: cfg.group, + type: cfg.type, + }, + cell: ({ row, cell }) => { + + if (cfg.id === "createdAt"||cfg.id === "updatedAt") { + const dateVal = cell.getValue() as Date + return formatDate(dateVal) + } + + return row.getValue(cfg.id) ?? "" + }, + } + + groupMap[groupName].push(childCol) + }) + + // ---------------------------------------------------------------- + // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기 + // ---------------------------------------------------------------- + const nestedColumns: ColumnDef<ViewTagSubfields>[] = [] + + // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함 + // 여기서는 그냥 Object.entries 순서 + Object.entries(groupMap).forEach(([groupName, colDefs]) => { + if (groupName === "_noGroup") { + // 그룹 없음 → 그냥 최상위 레벨 컬럼 + nestedColumns.push(...colDefs) + } else { + // 상위 컬럼 + nestedColumns.push({ + id: groupName, + header: groupName, // "Basic Info", "Metadata" 등 + columns: colDefs, + }) + } + }) + + // ---------------------------------------------------------------- + // 4) 최종 컬럼 배열: select, nestedColumns, actions + // ---------------------------------------------------------------- + return [ + ...nestedColumns, + actionsColumn, + ] +}
\ No newline at end of file diff --git a/lib/tag-numbering/table/tagNumbering-table-toolbar-actions.tsx b/lib/tag-numbering/table/tagNumbering-table-toolbar-actions.tsx new file mode 100644 index 00000000..1a7af254 --- /dev/null +++ b/lib/tag-numbering/table/tagNumbering-table-toolbar-actions.tsx @@ -0,0 +1,53 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Download, RefreshCcw, Upload } from "lucide-react" +import { toast } from "sonner" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" +import { ViewTagSubfields } from "@/db/schema/vendorData" + + + +interface ItemsTableToolbarActionsProps { + table: Table<ViewTagSubfields> +} + +export function TagNumberingTableToolbarActions({ table }: ItemsTableToolbarActionsProps) { + // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식 + const fileInputRef = React.useRef<HTMLInputElement>(null) + + + + return ( + <div className="flex items-center gap-2"> + {/** 4) Export 버튼 */} + <Button + variant="samsung" + size="sm" + className="gap-2" + > + <RefreshCcw className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Get Tag Numbering</span> + </Button> + + {/** 4) Export 버튼 */} + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(table, { + filename: "tasks", + excludeColumns: ["select", "actions"], + }) + } + className="gap-2" + > + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Export</span> + </Button> + </div> + ) +}
\ No newline at end of file diff --git a/lib/tag-numbering/table/tagNumbering-table.tsx b/lib/tag-numbering/table/tagNumbering-table.tsx new file mode 100644 index 00000000..7997aad9 --- /dev/null +++ b/lib/tag-numbering/table/tagNumbering-table.tsx @@ -0,0 +1,151 @@ +"use client" + +import * as React from "react" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" + +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import { useFeatureFlags } from "./feature-flags-provider" + +import { ViewTagSubfields } from "@/db/schema/vendorData" +import { getTagNumbering } from "../service" +import { getColumns } from "./tagNumbering-table-columns" +import { TagNumberingTableToolbarActions } from "./tagNumbering-table-toolbar-actions" +import { ViewTagOptions } from "./meta-sheet" + +interface ItemsTableProps { + promises: Promise< + [ + Awaited<ReturnType<typeof getTagNumbering>>, + ] + > +} + +export function TagNumberingTable({ promises }: ItemsTableProps) { + const { featureFlags } = useFeatureFlags() + + const [{ data, pageCount }] = + React.use(promises) + + + const [rowAction, setRowAction] = + React.useState<DataTableRowAction<ViewTagSubfields> | null>(null) + + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + /** + * This component can render either a faceted filter or a search filter based on the `options` prop. + * + * @prop options - An array of objects, each representing a filter option. If provided, a faceted filter is rendered. If not, a search filter is rendered. + * + * Each `option` object has the following properties: + * @prop {string} label - The label for the filter option. + * @prop {string} value - The value for the filter option. + * @prop {React.ReactNode} [icon] - An optional icon to display next to the label. + * @prop {boolean} [withCount] - An optional boolean to display the count of the filter option. + */ + const filterFields: DataTableFilterField<ViewTagSubfields>[] = [ + + ] + + /** + * Advanced filter fields for the data table. + * These fields provide more complex filtering options compared to the regular filterFields. + * + * Key differences from regular filterFields: + * 1. More field types: Includes 'text', 'multi-select', 'date', and 'boolean'. + * 2. Enhanced flexibility: Allows for more precise and varied filtering options. + * 3. Used with DataTableAdvancedToolbar: Enables a more sophisticated filtering UI. + * 4. Date and boolean types: Adds support for filtering by date ranges and boolean values. + */ + const advancedFilterFields: DataTableAdvancedFilterField<ViewTagSubfields>[] = [ + { + id: "tagTypeCode", + label: "Tag Type Code", + type: "text", + }, + { + id: "tagTypeDescription", + label: "Tag Type Description", + type: "text", + }, + + { + id: "attributesId", + label: "Attributes Id", + type: "text", + }, + + { + id: "attributesDescription", + label: "Attributes Description", + type: "text", + }, + { + id: "expression", + label: "expression", + type: "text", + }, + { + id: "createdAt", + label: "Created At", + type: "date", + }, + { + id: "updatedAt", + label: "Updated At", + type: "date", + }, + + ] + + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + <DataTable + table={table} + > + + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <TagNumberingTableToolbarActions table={table} /> + </DataTableAdvancedToolbar> + + </DataTable> + + <ViewTagOptions + open={rowAction?.type === "items"} + onOpenChange={() => setRowAction(null)} + tagSubfield={rowAction?.row.original ?? null} + /> + + </> + ) +} diff --git a/lib/tag-numbering/validation.ts b/lib/tag-numbering/validation.ts new file mode 100644 index 00000000..36199f24 --- /dev/null +++ b/lib/tag-numbering/validation.ts @@ -0,0 +1,39 @@ +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, +} from "nuqs/server" +import * as z from "zod" + +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" +import { ViewTagSubfields } from "@/db/schema/vendorData"; + +export const searchParamsCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault( + [] + ), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser<ViewTagSubfields>().withDefault([ + { id: "createdAt", desc: true }, + ]), + tagTypeCode: parseAsString.withDefault(""), + tagTypeDescription: parseAsString.withDefault(""), + attributesId: parseAsString.withDefault(""), + attributesDescription: parseAsString.withDefault(""), + expression: parseAsString.withDefault(""), + delimiter: parseAsString.withDefault(""), + sortOrder: parseAsString.withDefault(""), + + // advanced filter + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), + +}) + + + +export type GetTagNumberigSchema = Awaited<ReturnType<typeof searchParamsCache.parse>> diff --git a/lib/tags/form-mapping-service.ts b/lib/tags/form-mapping-service.ts new file mode 100644 index 00000000..4b772ab6 --- /dev/null +++ b/lib/tags/form-mapping-service.ts @@ -0,0 +1,65 @@ +"use server" + +import db from "@/db/db" +import { tagTypeClassFormMappings } from "@/db/schema/vendorData"; +import { eq, and } from "drizzle-orm" + +// 폼 정보 인터페이스 (동일) +export interface FormMapping { + formCode: string; + formName: string; +} + +/** + * 주어진 tagType, classCode로 DB를 조회하여 + * 1) 특정 classCode 매핑 => 존재하면 반환 + * 2) 없으면 DEFAULT 매핑 => 없으면 빈 배열 + */ +export async function getFormMappingsByTagType( + tagType: string, + classCode?: string +): Promise<FormMapping[]> { + + console.log(`DB-based getFormMappingsByTagType => tagType="${tagType}", class="${classCode ?? "NONE"}"`); + + // 1) classCode가 있으면 시도 + if (classCode) { + const specificRows = await db + .select({ + formCode: tagTypeClassFormMappings.formCode, + formName: tagTypeClassFormMappings.formName, + }) + .from(tagTypeClassFormMappings) + .where(and( + eq(tagTypeClassFormMappings.tagTypeLabel, tagType), + eq(tagTypeClassFormMappings.classLabel, classCode) + )) + + if (specificRows.length > 0) { + console.log("Found specific mapping rows:", specificRows.length); + return specificRows; + } + } + + // 2) fallback => DEFAULT + console.log(`Falling back to DEFAULT for tagType="${tagType}"`); + const defaultRows = await db + .select({ + formCode: tagTypeClassFormMappings.formCode, + formName: tagTypeClassFormMappings.formName, + }) + .from(tagTypeClassFormMappings) + .where(and( + eq(tagTypeClassFormMappings.tagTypeLabel, tagType), + eq(tagTypeClassFormMappings.classLabel, "DEFAULT") + )) + + if (defaultRows.length > 0) { + console.log("Using DEFAULT mapping rows:", defaultRows.length); + return defaultRows; + } + + // 3) 아무것도 없으면 빈 배열 + console.log(`No mappings found at all for tagType="${tagType}"`); + return []; +}
\ No newline at end of file diff --git a/lib/tags/repository.ts b/lib/tags/repository.ts new file mode 100644 index 00000000..b5d48335 --- /dev/null +++ b/lib/tags/repository.ts @@ -0,0 +1,71 @@ +import db from "@/db/db"; +import { NewTag, tags } from "@/db/schema/vendorData"; +import { + eq, + inArray, + not, + asc, + desc, + and, + ilike, + gte, + lte, + count, + gt, +} from "drizzle-orm"; +import { PgTransaction } from "drizzle-orm/pg-core"; + +export async function selectTags( + tx: PgTransaction<any, any, any>, + params: { + where?: any; // drizzle-orm의 조건식 (and, eq...) 등 + orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[]; + offset?: number; + limit?: number; + } +) { + const { where, orderBy, offset = 0, limit = 10 } = params; + + return tx + .select() + .from(tags) + .where(where) + .orderBy(...(orderBy ?? [])) + .offset(offset) + .limit(limit); +} +/** 총 개수 count */ +export async function countTags( + tx: PgTransaction<any, any, any>, + where?: any +) { + const res = await tx.select({ count: count() }).from(tags).where(where); + return res[0]?.count ?? 0; +} + +export async function insertTag( + tx: PgTransaction<any, any, any>, + data: NewTag // DB와 동일한 insert 가능한 타입 +) { + // returning() 사용 시 배열로 돌아오므로 [0]만 리턴 + return tx + .insert(tags) + .values(data) + .returning({ id: tags.id, createdAt: tags.createdAt }); +} + +/** 단건 삭제 */ +export async function deleteTagById( + tx: PgTransaction<any, any, any>, + tagId: number +) { + return tx.delete(tags).where(eq(tags.id, tagId)); +} + +/** 복수 삭제 */ +export async function deleteTagsByIds( + tx: PgTransaction<any, any, any>, + ids: number[] +) { + return tx.delete(tags).where(inArray(tags.id, ids)); +} diff --git a/lib/tags/service.ts b/lib/tags/service.ts new file mode 100644 index 00000000..efba2fd5 --- /dev/null +++ b/lib/tags/service.ts @@ -0,0 +1,796 @@ +"use server" + +import db from "@/db/db" +import { formEntries, forms, tagClasses, tags, tagSubfieldOptions, tagSubfields, tagTypes } from "@/db/schema/vendorData" +// import { eq } from "drizzle-orm" +import { createTagSchema, GetTagsSchema, updateTagSchema, UpdateTagSchema, type CreateTagSchema } from "./validations" +import { revalidateTag, unstable_noStore } from "next/cache"; +import { filterColumns } from "@/lib/filter-columns"; +import { unstable_cache } from "@/lib/unstable-cache"; +import { asc, desc, ilike, inArray, and, gte, lte, not, or, eq, sql, ne } from "drizzle-orm"; +import { countTags, deleteTagById, deleteTagsByIds, insertTag, selectTags } from "./repository"; +import { getErrorMessage } from "../handle-error"; +import { getFormMappingsByTagType } from './form-mapping-service'; +import { contractItems } from "@/db/schema/contract"; + + +// 폼 결과를 위한 인터페이스 정의 +interface CreatedOrExistingForm { + id: number; + formCode: string; + formName: string; + isNewlyCreated: boolean; +} + +export async function getTags(input: GetTagsSchema, packagesId: number) { + + return unstable_cache( + async () => { + try { + const offset = (input.page - 1) * input.perPage; + + // (1) advancedWhere + const advancedWhere = filterColumns({ + table: tags, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + // (2) globalWhere + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + ilike(tags.tagNo, s), + ilike(tags.tagType, s), + ilike(tags.description, s) + ); + } + // (4) 최종 where + const finalWhere = and(advancedWhere, globalWhere, eq(tags.contractItemId, packagesId)); + + // (5) 정렬 + const orderBy = + input.sort.length > 0 + ? input.sort.map((item) => + item.desc ? desc(tags[item.id]) : asc(tags[item.id]) + ) + : [asc(tags.createdAt)]; + + // 트랜잭션 내부에서 Repository 호출 + const { data, total } = await db.transaction(async (tx) => { + const data = await selectTags(tx, { + where: finalWhere, + orderBy, + offset, + limit: input.perPage, + }); + const total = await countTags(tx, finalWhere); + + + return { data, total }; + }); + + const pageCount = Math.ceil(total / input.perPage); + + + return { data, pageCount }; + } catch (err) { + // 에러 발생 시 디폴트 + return { data: [], pageCount: 0 }; + } + }, + [JSON.stringify(input), String(packagesId)], // 캐싱 키에 packagesId 추가 + { + revalidate: 3600, + tags: [`tags-${packagesId}`], // 패키지별 태그 사용 + } + )(); +} + +export async function createTag( + formData: CreateTagSchema, + selectedPackageId: number | null +) { + if (!selectedPackageId) { + return { error: "No selectedPackageId provided" } + } + + // Validate formData + const validated = createTagSchema.safeParse(formData) + if (!validated.success) { + return { error: validated.error.flatten().formErrors.join(", ") } + } + + // React 서버 액션에서 매 요청마다 실행 + unstable_noStore() + + try { + // 하나의 트랜잭션에서 모든 작업 수행 + return await db.transaction(async (tx) => { + // 1) 선택된 contractItem의 contractId 가져오기 + const contractItemResult = await tx + .select({ contractId: contractItems.contractId }) + .from(contractItems) + .where(eq(contractItems.id, selectedPackageId)) + .limit(1) + + if (contractItemResult.length === 0) { + return { error: "Contract item not found" } + } + + const contractId = contractItemResult[0].contractId + + // 2) 해당 계약 내에서 같은 tagNo를 가진 태그가 있는지 확인 + const duplicateCheck = await tx + .select({ count: sql<number>`count(*)` }) + .from(tags) + .innerJoin(contractItems, eq(tags.contractItemId, contractItems.id)) + .where( + and( + eq(contractItems.contractId, contractId), + eq(tags.tagNo, validated.data.tagNo) + ) + ) + + if (duplicateCheck[0].count > 0) { + return { + error: `태그 번호 "${validated.data.tagNo}"는 이미 이 계약 내에 존재합니다.`, + } + } + + // 3) 태그 타입에 따른 폼 정보 가져오기 + const formMappings = await getFormMappingsByTagType( + validated.data.tagType, + validated.data.class + ) + + // 폼 매핑이 없으면 로그만 남기고 진행 + if (!formMappings || formMappings.length === 0) { + console.log( + "No form mappings found for tag type:", + validated.data.tagType + ) + } + + // 4) 이 태그 타입에 대한 주요 폼(첫 번째 폼)을 찾거나 생성 + let primaryFormId: number | null = null + const createdOrExistingForms: CreatedOrExistingForm[] = [] + + if (formMappings && formMappings.length > 0) { + for (const formMapping of formMappings) { + // 4-1) 이미 존재하는 폼인지 확인 + const existingForm = await tx + .select({ id: forms.id }) + .from(forms) + .where( + and( + eq(forms.contractItemId, selectedPackageId), + eq(forms.formCode, formMapping.formCode) + ) + ) + .limit(1) + + let formId: number + if (existingForm.length > 0) { + // 이미 존재하면 해당 ID 사용 + formId = existingForm[0].id + createdOrExistingForms.push({ + id: formId, + formCode: formMapping.formCode, + formName: formMapping.formName, + isNewlyCreated: false, + }) + } else { + // 존재하지 않으면 새로 생성 + const insertResult = await tx + .insert(forms) + .values({ + contractItemId: selectedPackageId, + formCode: formMapping.formCode, + formName: formMapping.formName, + }) + .returning({ id: forms.id, formCode: forms.formCode, formName: forms.formName }) + + console.log("insertResult:", insertResult) + formId = insertResult[0].id + createdOrExistingForms.push({ + id: formId, + formCode: insertResult[0].formCode, + formName: insertResult[0].formName, + isNewlyCreated: true, + }) + } + + // 첫 번째 폼을 "주요 폼"으로 설정하여 태그 생성 시 사용 + if (primaryFormId === null) { + primaryFormId = formId + } + } + } + + // 5) 새 Tag 생성 (같은 트랜잭션 `tx` 사용) + const [newTag] = await insertTag(tx, { + contractItemId: selectedPackageId, + formId: primaryFormId, + tagNo: validated.data.tagNo, + class: validated.data.class, + tagType: validated.data.tagType, + description: validated.data.description ?? null, + }) + + console.log(`tags-${selectedPackageId}`, "create", newTag) + + // 6) 캐시 무효화 (React 서버 액션에서 캐싱 사용 시) + revalidateTag(`tags-${selectedPackageId}`) + revalidateTag(`forms-${selectedPackageId}`) + revalidateTag("tags") + + // 7) 성공 시 반환 + return { + success: true, + data: { + forms: createdOrExistingForms, + primaryFormId, + }, + } + }) + } catch (err: any) { + console.error("createTag error:", err) + return { error: getErrorMessage(err) } + } +} + +export async function updateTag( + formData: UpdateTagSchema & { id: number }, + selectedPackageId: number | null +) { + if (!selectedPackageId) { + return { error: "No selectedPackageId provided" } + } + + if (!formData.id) { + return { error: "No tag ID provided" } + } + + // Validate formData + const validated = updateTagSchema.safeParse(formData) + if (!validated.success) { + return { error: validated.error.flatten().formErrors.join(", ") } + } + + // React 서버 액션에서 매 요청마다 실행 + unstable_noStore() + + try { + // 하나의 트랜잭션에서 모든 작업 수행 + return await db.transaction(async (tx) => { + // 1) 기존 태그 존재 여부 확인 + const existingTag = await tx + .select() + .from(tags) + .where(eq(tags.id, formData.id)) + .limit(1) + + if (existingTag.length === 0) { + return { error: "태그를 찾을 수 없습니다." } + } + + const originalTag = existingTag[0] + + // 2) 선택된 contractItem의 contractId 가져오기 + const contractItemResult = await tx + .select({ contractId: contractItems.contractId }) + .from(contractItems) + .where(eq(contractItems.id, selectedPackageId)) + .limit(1) + + if (contractItemResult.length === 0) { + return { error: "Contract item not found" } + } + + const contractId = contractItemResult[0].contractId + + // 3) 태그 번호가 변경되었고, 해당 계약 내에서 같은 tagNo를 가진 다른 태그가 있는지 확인 + if (originalTag.tagNo !== validated.data.tagNo) { + const duplicateCheck = await tx + .select({ count: sql<number>`count(*)` }) + .from(tags) + .innerJoin(contractItems, eq(tags.contractItemId, contractItems.id)) + .where( + and( + eq(contractItems.contractId, contractId), + eq(tags.tagNo, validated.data.tagNo), + ne(tags.id, formData.id) // 자기 자신은 제외 + ) + ) + + if (duplicateCheck[0].count > 0) { + return { + error: `태그 번호 "${validated.data.tagNo}"는 이미 이 계약 내에 존재합니다.`, + } + } + } + + // 4) 태그 타입이나 클래스가 변경되었는지 확인 + const isTagTypeOrClassChanged = + originalTag.tagType !== validated.data.tagType || + originalTag.class !== validated.data.class + + let primaryFormId = originalTag.formId + + // 태그 타입이나 클래스가 변경되었다면 연관된 폼 업데이트 + if (isTagTypeOrClassChanged) { + // 4-1) 태그 타입에 따른 폼 정보 가져오기 + const formMappings = await getFormMappingsByTagType( + validated.data.tagType, + validated.data.class + ) + + // 폼 매핑이 없으면 로그만 남기고 진행 + if (!formMappings || formMappings.length === 0) { + console.log( + "No form mappings found for tag type:", + validated.data.tagType + ) + } + + // 4-2) 이 태그 타입에 대한 주요 폼(첫 번째 폼)을 찾거나 생성 + const createdOrExistingForms: CreatedOrExistingForm[] = [] + + if (formMappings && formMappings.length > 0) { + for (const formMapping of formMappings) { + // 이미 존재하는 폼인지 확인 + const existingForm = await tx + .select({ id: forms.id }) + .from(forms) + .where( + and( + eq(forms.contractItemId, selectedPackageId), + eq(forms.formCode, formMapping.formCode) + ) + ) + .limit(1) + + let formId: number + if (existingForm.length > 0) { + // 이미 존재하면 해당 ID 사용 + formId = existingForm[0].id + createdOrExistingForms.push({ + id: formId, + formCode: formMapping.formCode, + formName: formMapping.formName, + isNewlyCreated: false, + }) + } else { + // 존재하지 않으면 새로 생성 + const insertResult = await tx + .insert(forms) + .values({ + contractItemId: selectedPackageId, + formCode: formMapping.formCode, + formName: formMapping.formName, + }) + .returning({ id: forms.id, formCode: forms.formCode, formName: forms.formName }) + + formId = insertResult[0].id + createdOrExistingForms.push({ + id: formId, + formCode: insertResult[0].formCode, + formName: insertResult[0].formName, + isNewlyCreated: true, + }) + } + + // 첫 번째 폼을 "주요 폼"으로 설정하여 태그 업데이트 시 사용 + if (createdOrExistingForms.length === 1) { + primaryFormId = formId + } + } + } + } + + // 5) 태그 업데이트 + const [updatedTag] = await tx + .update(tags) + .set({ + contractItemId: selectedPackageId, + formId: primaryFormId, + tagNo: validated.data.tagNo, + class: validated.data.class, + tagType: validated.data.tagType, + description: validated.data.description ?? null, + updatedAt: new Date(), + }) + .where(eq(tags.id, formData.id)) + .returning() + + // 6) 캐시 무효화 (React 서버 액션에서 캐싱 사용 시) + revalidateTag(`tags-${selectedPackageId}`) + revalidateTag(`forms-${selectedPackageId}`) + revalidateTag("tags") + + // 7) 성공 시 반환 + return { + success: true, + data: { + tag: updatedTag, + formUpdated: isTagTypeOrClassChanged + }, + } + }) + } catch (err: any) { + console.error("updateTag error:", err) + return { error: getErrorMessage(err) } + } +} + +export interface TagInputData { + tagNo: string; + class: string; + tagType: string; + description?: string | null; + formId?: number | null; + [key: string]: any; +} +// 새로운 서버 액션 +export async function bulkCreateTags( + tagsfromExcel: TagInputData[], + selectedPackageId: number +) { + unstable_noStore(); + + if (!tagsfromExcel.length) { + return { error: "No tags provided" }; + } + + try { + // 단일 트랜잭션으로 모든 작업 처리 + return await db.transaction(async (tx) => { + // 1. 컨트랙트 ID 조회 (한 번만) + const contractItemResult = await tx + .select({ contractId: contractItems.contractId }) + .from(contractItems) + .where(eq(contractItems.id, selectedPackageId)) + .limit(1); + + if (contractItemResult.length === 0) { + return { error: "Contract item not found" }; + } + + const contractId = contractItemResult[0].contractId; + + // 2. 모든 태그 번호 중복 검사 (한 번에) + const tagNos = tagsfromExcel.map(tag => tag.tagNo); + const duplicateCheck = await tx + .select({ tagNo: tags.tagNo }) + .from(tags) + .innerJoin(contractItems, eq(tags.contractItemId, contractItems.id)) + .where(and( + eq(contractItems.contractId, contractId), + inArray(tags.tagNo, tagNos) + )); + + if (duplicateCheck.length > 0) { + return { + error: `태그 번호 "${duplicateCheck.map(d => d.tagNo).join(', ')}"는 이미 존재합니다.` + }; + } + + // 3. 태그별 폼 정보 처리 및 태그 생성 + const createdTags = []; + + for (const tagData of tagsfromExcel) { + // 각 태그 유형에 대한 폼 처리 (createTag 함수와 유사한 로직) + const formMappings = await getFormMappingsByTagType(tagData.tagType, tagData.class); + let primaryFormId = null; + + // 폼 처리 로직 (생략...) + + // 태그 생성 + const [newTag] = await insertTag(tx, { + contractItemId: selectedPackageId, + formId: primaryFormId, + tagNo: tagData.tagNo, + class: tagData.class, + tagType: tagData.tagType, + description: tagData.description || null, + }); + + createdTags.push(newTag); + } + + // 4. 캐시 무효화 (한 번만) + revalidateTag(`tags-${selectedPackageId}`); + revalidateTag(`forms-${selectedPackageId}`); + revalidateTag("tags"); + + return { + success: true, + data: { + createdCount: createdTags.length, + tags: createdTags + } + }; + }); + } catch (err: any) { + console.error("bulkCreateTags error:", err); + return { error: err.message || "Failed to create tags" }; + } +} + + +/** 복수 삭제 */ +interface RemoveTagsInput { + ids: number[]; + selectedPackageId: number; +} + + +// formEntries의 data JSON에서 tagNo가 일치하는 객체를 제거해주는 예시 함수 +function removeTagFromDataJson( + dataJson: any, + tagNo: string +): any { + // data 구조가 어떻게 생겼는지에 따라 로직이 달라집니다. + // 예: data 배열 안에 { tagNumber: string, ... } 형태로 여러 객체가 있다고 가정 + if (!Array.isArray(dataJson)) return dataJson + return dataJson.filter((entry) => entry.tagNumber !== tagNo) +} + +export async function removeTags(input: RemoveTagsInput) { + unstable_noStore() // React 서버 액션 무상태 함수 + + const { ids, selectedPackageId } = input + + try { + await db.transaction(async (tx) => { + // 1) 삭제 대상 tag들을 미리 조회 (tagNo, tagType, class 등을 얻기 위함) + const tagsToDelete = await tx + .select({ + id: tags.id, + tagNo: tags.tagNo, + tagType: tags.tagType, + class: tags.class, + }) + .from(tags) + .where(inArray(tags.id, ids)) + + // 2) 각 tag마다 관련된 formCode를 찾고, forms & formEntries 처리를 수행 + for (const tagInfo of tagsToDelete) { + const { tagNo, tagType, class: tagClass } = tagInfo + + // 2-1) tagTypeClassFormMappings(혹은 대응되는 로직)에서 formCode 목록 가져오기 + const formMappings = await getFormMappingsByTagType(tagType, tagClass) + if (!formMappings) continue + + // 2-2) 얻어온 formCode 리스트를 순회하면서, forms 테이블과 formEntries 테이블 처리 + for (const fm of formMappings) { + // (A) forms 테이블 삭제 + // - 조건: contractItemId=selectedPackageId, formCode=fm.formCode + await tx + .delete(forms) + .where( + and( + eq(forms.contractItemId, selectedPackageId), + eq(forms.formCode, fm.formCode) + ) + ) + + // (B) formEntries 테이블 JSON에서 tagNo 제거 → 업데이트 + // - 예: formEntries 안에 (id, contractItemId, formCode, data(=json)) 칼럼 존재 가정 + const formEntryRecords = await tx + .select({ + id: formEntries.id, + data: formEntries.data, + }) + .from(formEntries) + .where( + and( + eq(formEntries.contractItemId, selectedPackageId), + eq(formEntries.formCode, fm.formCode) + ) + ) + + // 여러 formEntries 레코드가 있을 수도 있어서 모두 처리 + for (const entry of formEntryRecords) { + const updatedJson = removeTagFromDataJson(entry.data, tagNo) + + // 변경이 있다면 업데이트 + await tx + .update(formEntries) + .set({ data: updatedJson }) + .where(eq(formEntries.id, entry.id)) + } + } + } + + // 3) 마지막으로 실제로 tags 테이블에서 Tag들을 삭제 + // (Tag → forms → formEntries 순서대로 처리) + await tx.delete(tags).where(inArray(tags.id, ids)) + }) + + // 4) 캐시 무효화 + // revalidateTag("tags") + revalidateTag(`tags-${selectedPackageId}`) + revalidateTag(`forms-${selectedPackageId}`) + + return { data: null, error: null } + } catch (err) { + return { data: null, error: getErrorMessage(err) } + } +} + +// Updated service functions to support the new schema + +// 업데이트된 ClassOption 타입 +export interface ClassOption { + code: string; + label: string; + tagTypeCode: string; // 클래스와 연결된 태그 타입 코드 + tagTypeDescription?: string; // 태그 타입의 설명 (선택적) +} + +/** + * Class 옵션 목록을 가져오는 함수 + * 이제 각 클래스는 연결된 tagTypeCode와 tagTypeDescription을 포함 + */ +export async function getClassOptions(){ + const rows = await db + .select({ + id: tagClasses.id, + code: tagClasses.code, + label: tagClasses.label, + tagTypeCode: tagClasses.tagTypeCode, + tagTypeDescription: tagTypes.description, + }) + .from(tagClasses) + .leftJoin(tagTypes, eq(tagTypes.code, tagClasses.tagTypeCode)) + + return rows.map((row) => ({ + code: row.code, + label: row.label, + tagTypeCode: row.tagTypeCode, + tagTypeDescription: row.tagTypeDescription ?? "", + })) +} + +interface SubFieldDef { + name: string + label: string + type: "select" | "text" + options: { value: string; label: string }[] + expression: string | null + delimiter: string | null +} + +export async function getSubfieldsByTagType(tagTypeCode: string) { + try { + const rows = await db + .select() + .from(tagSubfields) + .where(eq(tagSubfields.tagTypeCode, tagTypeCode)) + .orderBy(asc(tagSubfields.sortOrder)) + + // 각 row -> SubFieldDef + const formattedSubFields: SubFieldDef[] = [] + for (const sf of rows) { + const subfieldType = await getSubfieldType(sf.attributesId) + const subfieldOptions = subfieldType === "select" + ? await getSubfieldOptions(sf.attributesId) + : [] + + formattedSubFields.push({ + name: sf.attributesId.toLowerCase(), + label: sf.attributesDescription, + type: subfieldType, + options: subfieldOptions, + expression: sf.expression, + delimiter: sf.delimiter, + }) + } + + return { subFields: formattedSubFields } + } catch (error) { + console.error("Error fetching subfields by tag type:", error) + throw new Error("Failed to fetch subfields") + } +} + + +async function getSubfieldType(attributesId: string): Promise<"select" | "text"> { + const optRows = await db + .select() + .from(tagSubfieldOptions) + .where(eq(tagSubfieldOptions.attributesId, attributesId)) + + return optRows.length > 0 ? "select" : "text" +} + +export interface SubfieldOption { + /** + * 옵션의 실제 값 (데이터베이스에 저장될 값) + * 예: "PM", "AA", "VB", "01" 등 + */ + value: string; + + /** + * 옵션의 표시 레이블 (사용자에게 보여질 텍스트) + * 예: "Pump", "Pneumatic Motor", "Ball Valve" 등 + */ + label: string; +} + + + +/** + * SubField의 옵션 목록을 가져오는 보조 함수 + */ +async function getSubfieldOptions(attributesId: string): Promise<SubfieldOption[]> { + try { + const rows = await db + .select({ + code: tagSubfieldOptions.code, + label: tagSubfieldOptions.label + }) + .from(tagSubfieldOptions) + .where(eq(tagSubfieldOptions.attributesId, attributesId)) + + return rows.map((row) => ({ + value: row.code, + label: row.label + })) + } catch (error) { + console.error(`Error fetching options for attribute ${attributesId}:`, error) + return [] + } +} + + +/** + * Tag Type 목록을 가져오는 함수 + * 이제 tagTypes 테이블에서 직접 데이터를 가져옴 + */ +export async function getTagTypes(): Promise<{ options: TagTypeOption[] }> { + return unstable_cache( + async () => { + console.log(`[Server] Fetching tag types from tagTypes table`) + + try { + // 이제 tagSubfields가 아닌 tagTypes 테이블에서 직접 조회 + const result = await db + .select({ + code: tagTypes.code, + description: tagTypes.description, + }) + .from(tagTypes) + .orderBy(tagTypes.description); + + // TagTypeOption 형식으로 변환 + const tagTypeOptions: TagTypeOption[] = result.map(item => ({ + id: item.code, // id 필드에 code 값 할당 + label: item.description, // label 필드에 description 값 할당 + })); + + console.log(`[Server] Found ${tagTypeOptions.length} tag types`) + return { options: tagTypeOptions }; + } catch (error) { + console.error('[Server] Error fetching tag types:', error) + return { options: [] } + } + }, + ['tag-types-list'], + { + revalidate: 3600, // 1시간 캐시 + tags: ['tag-types'] + } + )() +} + +/** + * TagTypeOption 인터페이스 정의 + */ +export interface TagTypeOption { + id: string; // tagTypes.code 값 + label: string; // tagTypes.description 값 +}
\ No newline at end of file diff --git a/lib/tags/table/add-tag-dialog copy.tsx b/lib/tags/table/add-tag-dialog copy.tsx new file mode 100644 index 00000000..e9f84933 --- /dev/null +++ b/lib/tags/table/add-tag-dialog copy.tsx @@ -0,0 +1,637 @@ +"use client" + +import * as React from "react" +import { useRouter } from "next/navigation" // <-- 1) Import router from App Router +import { useForm, useWatch } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { toast } from "sonner" +import { Loader2, ChevronsUpDown, Check } from "lucide-react" + +import { + Dialog, + DialogTrigger, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { + Form, + FormField, + FormItem, + FormControl, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Popover, + PopoverTrigger, + PopoverContent, +} from "@/components/ui/popover" +import { + Command, + CommandInput, + CommandList, + CommandGroup, + CommandItem, + CommandEmpty, +} from "@/components/ui/command" +import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@/components/ui/select" +import { cn } from "@/lib/utils" + +import type { CreateTagSchema } from "@/lib/tags/validations" +import { createTagSchema } from "@/lib/tags/validations" +import { + createTag, + getSubfieldsByTagType, + getClassOptions, + type ClassOption, + TagTypeOption, +} from "@/lib/tags/service" + +// SubFieldDef for clarity +interface SubFieldDef { + name: string + label: string + type: "select" | "text" + options?: { value: string; label: string }[] + expression?: string + delimiter?: string +} + +// 클래스 옵션 인터페이스 +interface UpdatedClassOption extends ClassOption { + tagTypeCode: string + tagTypeDescription?: string +} + +interface AddTagDialogProps { + selectedPackageId: number | null +} + +export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) { + const router = useRouter() // <-- 2) Use the router hook + + const [open, setOpen] = React.useState(false) + const [tagTypeList, setTagTypeList] = React.useState<TagTypeOption[]>([]) + const [selectedTagTypeCode, setSelectedTagTypeCode] = React.useState<string | null>(null) + const [subFields, setSubFields] = React.useState<SubFieldDef[]>([]) + const [classOptions, setClassOptions] = React.useState<UpdatedClassOption[]>([]) + const [classSearchTerm, setClassSearchTerm] = React.useState("") + const [isLoadingClasses, setIsLoadingClasses] = React.useState(false) + const [isLoadingSubFields, setIsLoadingSubFields] = React.useState(false) + const [isSubmitting, setIsSubmitting] = React.useState(false) + + // ID management + const selectIdRef = React.useRef(0) + const getUniqueSelectId = React.useCallback(() => `select-${selectIdRef.current++}`, []) + const fieldIdsRef = React.useRef<Record<string, string>>({}) + const classOptionIdsRef = React.useRef<Record<string, string>>({}) + + // --------------- + // Load Class Options + // --------------- + React.useEffect(() => { + const loadClassOptions = async () => { + setIsLoadingClasses(true) + try { + const result = await getClassOptions() + setClassOptions(result) + } catch (err) { + toast.error("Failed to load class options") + } finally { + setIsLoadingClasses(false) + } + } + + if (open) { + loadClassOptions() + } + }, [open]) + + // --------------- + // react-hook-form + // --------------- + const form = useForm<CreateTagSchema>({ + resolver: zodResolver(createTagSchema), + defaultValues: { + tagType: "", + tagNo: "", + description: "", + functionCode: "", + seqNumber: "", + valveAcronym: "", + processUnit: "", + class: "", + }, + }) + + // watch + const { tagNo, ...fieldsToWatch } = useWatch({ + control: form.control, + }) + + // --------------- + // Load subfields by TagType code + // --------------- + async function loadSubFieldsByTagTypeCode(tagTypeCode: string) { + setIsLoadingSubFields(true) + try { + const { subFields: apiSubFields } = await getSubfieldsByTagType(tagTypeCode) + const formattedSubFields: SubFieldDef[] = apiSubFields.map(field => ({ + name: field.name, + label: field.label, + type: field.type, + options: field.options || [], + expression: field.expression ?? undefined, + delimiter: field.delimiter ?? undefined, + })) + setSubFields(formattedSubFields) + selectIdRef.current = 0 + return true + } catch (err) { + toast.error("Failed to load subfields") + setSubFields([]) + return false + } finally { + setIsLoadingSubFields(false) + } + } + + // --------------- + // Handle class selection + // --------------- + async function handleSelectClass(classOption: UpdatedClassOption) { + form.setValue("class", classOption.label) + if (classOption.tagTypeCode) { + setSelectedTagTypeCode(classOption.tagTypeCode) + // If you have tagTypeList, you can find the label + const tagType = tagTypeList.find(t => t.id === classOption.tagTypeCode) + if (tagType) { + form.setValue("tagType", tagType.label) + } else if (classOption.tagTypeDescription) { + form.setValue("tagType", classOption.tagTypeDescription) + } + await loadSubFieldsByTagTypeCode(classOption.tagTypeCode) + } + } + + // --------------- + // Render subfields + // --------------- + function renderSubFields() { + if (isLoadingSubFields) { + return ( + <div className="flex justify-center items-center py-8"> + <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /> + <span className="ml-3 text-muted-foreground">Loading fields...</span> + </div> + ) + } + if (subFields.length === 0 && selectedTagTypeCode) { + return ( + <div className="py-4 text-center text-muted-foreground"> + No fields available for this tag type. + </div> + ) + } + if (subFields.length === 0) { + return null + } + + return subFields.map((sf, index) => { + if (!fieldIdsRef.current[`${sf.name}-${index}`]) { + fieldIdsRef.current[`${sf.name}-${index}`] = + `field-${sf.name}-${Date.now()}-${Math.random().toString(36).slice(2, 9)}` + } + const fieldId = fieldIdsRef.current[`${sf.name}-${index}`] + const selectId = getUniqueSelectId() + + return ( + <FormField + key={fieldId} + control={form.control} + name={sf.name as keyof CreateTagSchema} + render={({ field }) => ( + <FormItem> + <FormLabel>{sf.label}</FormLabel> + <FormControl> + {sf.type === "select" ? ( + <Select + value={field.value || ""} + onValueChange={field.onChange} + > + <SelectTrigger className="w-full"> + <SelectValue + placeholder={`Select ${sf.label}`} + className={ + !field.value ? "text-muted-foreground text-opacity-60" : "" + } + /> + </SelectTrigger> + <SelectContent + align="start" + side="bottom" + style={{ width: 400, maxWidth: 400 }} + sideOffset={4} + id={selectId} + > + {sf.options?.map((opt, optIndex) => { + const optionKey = `${fieldId}-option-${opt.value}-${optIndex}` + return ( + <SelectItem + key={optionKey} + value={opt.value} + className="multi-line-select-item pr-6" + title={opt.label} + > + {opt.label} + </SelectItem> + ) + })} + </SelectContent> + </Select> + ) : ( + <Input + placeholder={`Enter ${sf.label}`} + {...field} + className={ + !field.value + ? "placeholder:text-muted-foreground placeholder:text-opacity-60" + : "" + } + /> + )} + </FormControl> + <FormMessage> + {sf.expression && ( + <span + className="text-xs text-muted-foreground truncate block" + title={sf.expression} + > + 형식: {sf.expression} + </span> + )} + </FormMessage> + </FormItem> + )} + /> + ) + }) + } + + // --------------- + // Build TagNo from subfields automatically + // --------------- + React.useEffect(() => { + if (subFields.length === 0) { + form.setValue("tagNo", "", { shouldDirty: false }) + } + + const subscription = form.watch((value, { name }) => { + if (!name || name === "tagNo" || subFields.length === 0) { + return + } + let combined = "" + subFields.forEach((sf, idx) => { + const fieldValue = form.getValues(sf.name as keyof CreateTagSchema) || "" + combined += fieldValue + if (fieldValue && idx < subFields.length - 1 && sf.delimiter) { + combined += sf.delimiter + } + }) + const currentTagNo = form.getValues("tagNo") + if (currentTagNo !== combined) { + form.setValue("tagNo", combined, { + shouldDirty: false, + shouldTouch: false, + shouldValidate: false, + }) + } + }) + + return () => subscription.unsubscribe() + }, [subFields, form]) + + // --------------- + // Basic validation for TagNo + // --------------- + const isTagNoValid = React.useMemo(() => { + const val = form.getValues("tagNo") + return val && val.trim() !== "" && !val.includes("??") + }, [fieldsToWatch]) + + // --------------- + // Submit handler + // --------------- + async function onSubmit(data: CreateTagSchema) { + if (!selectedPackageId) { + toast.error("No selectedPackageId.") + return + } + setIsSubmitting(true) + try { + const res = await createTag(data, selectedPackageId) + if ("error" in res) { + toast.error(`Error: ${res.error}`) + return + } + + toast.success("Tag created successfully!") + + // 3) Refresh or navigate after creation: + // Option A: If you just want to refresh the same route: + router.refresh() + + // Option B: If you want to go to /partners/vendor-data/tag/{selectedPackageId} + // router.push(`/partners/vendor-data/tag/${selectedPackageId}?r=${Date.now()}`) + + // (If you want to reset the form dialog or close it, do that too) + form.reset() + setOpen(false) + } catch (err) { + toast.error("Failed to create tag.") + } finally { + setIsSubmitting(false) + } + } + + // --------------- + // Render Class field + // --------------- + function renderClassField(field: any) { + const [popoverOpen, setPopoverOpen] = React.useState(false) + + const buttonId = React.useMemo( + () => `class-button-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, + [] + ) + const popoverContentId = React.useMemo( + () => `class-popover-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, + [] + ) + const commandId = React.useMemo( + () => `class-command-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, + [] + ) + + return ( + <FormItem> + <FormLabel>Class</FormLabel> + <FormControl> + <Popover open={popoverOpen} onOpenChange={setPopoverOpen}> + <PopoverTrigger asChild> + <Button + key={buttonId} + type="button" + variant="outline" + className="w-full justify-between" + disabled={isLoadingClasses} + > + {isLoadingClasses ? ( + <> + <span>Loading classes...</span> + <Loader2 className="ml-2 h-4 w-4 animate-spin" /> + </> + ) : ( + <> + <span className="truncate"> + {field.value || "Select Class..."} + </span> + <ChevronsUpDown className="ml-2 h-4 w-4 opacity-50" /> + </> + )} + </Button> + </PopoverTrigger> + <PopoverContent key={popoverContentId} className="w-full p-0"> + <Command key={commandId}> + <CommandInput + key={`${commandId}-input`} + placeholder="Search Class..." + value={classSearchTerm} + onValueChange={setClassSearchTerm} + /> + <CommandList key={`${commandId}-list`}> + <CommandEmpty key={`${commandId}-empty`}>No class found.</CommandEmpty> + <CommandGroup key={`${commandId}-group`}> + {classOptions.map((opt) => { + if (!classOptionIdsRef.current[opt.code]) { + classOptionIdsRef.current[opt.code] = + `class-${opt.code}-${Date.now()}-${Math.random() + .toString(36) + .slice(2, 9)}` + } + const optionId = classOptionIdsRef.current[opt.code] + + return ( + <CommandItem + key={optionId} + onSelect={() => { + field.onChange(opt.label) + setPopoverOpen(false) + handleSelectClass(opt) + }} + value={opt.label} + className="truncate" + title={opt.label} + > + <span className="truncate">{opt.label}</span> + <Check + key={`${optionId}-check`} + className={cn( + "ml-auto h-4 w-4 flex-shrink-0", + field.value === opt.label ? "opacity-100" : "opacity-0" + )} + /> + </CommandItem> + ) + })} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + </FormControl> + <FormMessage /> + </FormItem> + ) + } + + // --------------- + // Render TagType field (readonly after class selection) + // --------------- + function renderTagTypeField(field: any) { + const isReadOnly = !!selectedTagTypeCode + const inputId = React.useMemo( + () => + `tag-type-input-${isReadOnly ? "readonly" : "editable"}-${Date.now()}-${Math.random() + .toString(36) + .slice(2, 9)}`, + [isReadOnly] + ) + + return ( + <FormItem> + <FormLabel>Tag Type</FormLabel> + <FormControl> + {isReadOnly ? ( + <Input + key={`tag-type-readonly-${inputId}`} + {...field} + readOnly + className="bg-muted" + /> + ) : ( + <Input + key={`tag-type-placeholder-${inputId}`} + {...field} + readOnly + placeholder="Tag Type is determined by selected Class" + className="bg-muted" + /> + )} + </FormControl> + <FormMessage /> + </FormItem> + ) + } + + // --------------- + // Reset IDs/states when dialog closes + // --------------- + React.useEffect(() => { + if (!open) { + fieldIdsRef.current = {} + classOptionIdsRef.current = {} + selectIdRef.current = 0 + } + }, [open]) + + return ( + <Dialog + open={open} + onOpenChange={(o) => { + if (!o) { + form.reset() + setSelectedTagTypeCode(null) + setSubFields([]) + } + setOpen(o) + }} + > + <DialogTrigger asChild> + <Button variant="default" size="sm"> + Add Tag + </Button> + </DialogTrigger> + + <DialogContent className="max-h-[80vh] flex flex-col"> + <DialogHeader> + <DialogTitle>Add New Tag</DialogTitle> + <DialogDescription> + Choose a Class, and the Tag Type and subfields will be automatically loaded. + </DialogDescription> + </DialogHeader> + + <Form {...form}> + <form + onSubmit={form.handleSubmit(onSubmit)} + className="max-h-[70vh] flex flex-col" + > + <div className="flex-1 overflow-auto px-4 space-y-4"> + {/* Class */} + <FormField + key="class-field" + control={form.control} + name="class" + render={({ field }) => renderClassField(field)} + /> + + {/* TagType (read-only) */} + <FormField + key="tag-type-field" + control={form.control} + name="tagType" + render={({ field }) => renderTagTypeField(field)} + /> + + {/* SubFields */} + <div className="flex-1 overflow-auto px-2 py-2 space-y-4 max-h-[300px]"> + {renderSubFields()} + </div> + + {/* TagNo (read-only) */} + <FormField + key="tag-no-field" + control={form.control} + name="tagNo" + render={({ field }) => ( + <FormItem> + <FormLabel>Tag No</FormLabel> + <FormControl> + <Input + {...field} + readOnly + className="bg-muted truncate" + title={field.value || ""} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Description */} + <FormField + key="description-field" + control={form.control} + name="description" + render={({ field }) => ( + <FormItem> + <FormLabel>Description</FormLabel> + <FormControl> + <Input + {...field} + placeholder="Enter description..." + className="truncate" + title={field.value || ""} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + + {/* Footer */} + <DialogFooter className="bg-background z-10 pt-4 px-4 py-4"> + <Button + type="button" + variant="outline" + onClick={() => { + form.reset() + setOpen(false) + setSubFields([]) + setSelectedTagTypeCode(null) + }} + disabled={isSubmitting || isLoadingSubFields} + > + Cancel + </Button> + <Button + type="submit" + disabled={isSubmitting || isLoadingSubFields || !isTagNoValid} + > + {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + Create + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/tags/table/add-tag-dialog.tsx b/lib/tags/table/add-tag-dialog.tsx new file mode 100644 index 00000000..3814761d --- /dev/null +++ b/lib/tags/table/add-tag-dialog.tsx @@ -0,0 +1,893 @@ +"use client" + +import * as React from "react" +import { useRouter } from "next/navigation" +import { useForm, useWatch, useFieldArray } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { toast } from "sonner" +import { Loader2, ChevronsUpDown, Check, Plus, Trash2, Copy } from "lucide-react" + +import { + Dialog, + DialogTrigger, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { + Form, + FormField, + FormItem, + FormControl, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Popover, + PopoverTrigger, + PopoverContent, +} from "@/components/ui/popover" +import { + Command, + CommandInput, + CommandList, + CommandGroup, + CommandItem, + CommandEmpty, +} from "@/components/ui/command" +import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@/components/ui/select" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" +import { cn } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" + +import type { CreateTagSchema } from "@/lib/tags/validations" +import { createTagSchema } from "@/lib/tags/validations" +import { + createTag, + getSubfieldsByTagType, + getClassOptions, + type ClassOption, + TagTypeOption, +} from "@/lib/tags/service" + +// Updated to support multiple rows +interface MultiTagFormValues { + class: string; + tagType: string; + rows: Array<{ + [key: string]: string; + tagNo: string; + description: string; + }>; +} + +// SubFieldDef for clarity +interface SubFieldDef { + name: string + label: string + type: "select" | "text" + options?: { value: string; label: string }[] + expression?: string + delimiter?: string +} + +// 클래스 옵션 인터페이스 +interface UpdatedClassOption extends ClassOption { + tagTypeCode: string + tagTypeDescription?: string +} + +interface AddTagDialogProps { + selectedPackageId: number | null +} + +export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) { + const router = useRouter() + + const [open, setOpen] = React.useState(false) + const [tagTypeList, setTagTypeList] = React.useState<TagTypeOption[]>([]) + const [selectedTagTypeCode, setSelectedTagTypeCode] = React.useState<string | null>(null) + const [subFields, setSubFields] = React.useState<SubFieldDef[]>([]) + const [classOptions, setClassOptions] = React.useState<UpdatedClassOption[]>([]) + const [classSearchTerm, setClassSearchTerm] = React.useState("") + const [isLoadingClasses, setIsLoadingClasses] = React.useState(false) + const [isLoadingSubFields, setIsLoadingSubFields] = React.useState(false) + const [isSubmitting, setIsSubmitting] = React.useState(false) + + // ID management + const selectIdRef = React.useRef(0) + const getUniqueSelectId = React.useCallback(() => `select-${selectIdRef.current++}`, []) + const fieldIdsRef = React.useRef<Record<string, string>>({}) + const classOptionIdsRef = React.useRef<Record<string, string>>({}) + + console.log(subFields) + + // --------------- + // Load Class Options + // --------------- + React.useEffect(() => { + const loadClassOptions = async () => { + setIsLoadingClasses(true) + try { + const result = await getClassOptions() + setClassOptions(result) + } catch (err) { + toast.error("클래스 옵션을 불러오는데 실패했습니다.") + } finally { + setIsLoadingClasses(false) + } + } + + if (open) { + loadClassOptions() + } + }, [open]) + + // --------------- + // react-hook-form with fieldArray support for multiple rows + // --------------- + const form = useForm<MultiTagFormValues>({ + defaultValues: { + tagType: "", + class: "", + rows: [{ + tagNo: "", + description: "" + }] + }, + }) + + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: "rows" + }) + + // --------------- + // Load subfields by TagType code + // --------------- + async function loadSubFieldsByTagTypeCode(tagTypeCode: string) { + setIsLoadingSubFields(true) + try { + const { subFields: apiSubFields } = await getSubfieldsByTagType(tagTypeCode) + const formattedSubFields: SubFieldDef[] = apiSubFields.map(field => ({ + name: field.name, + label: field.label, + type: field.type, + options: field.options || [], + expression: field.expression ?? undefined, + delimiter: field.delimiter ?? undefined, + })) + setSubFields(formattedSubFields) + + // Initialize the rows with these subfields + const currentRows = form.getValues("rows"); + const updatedRows = currentRows.map(row => { + const newRow = { ...row }; + formattedSubFields.forEach(field => { + if (!newRow[field.name]) { + newRow[field.name] = ""; + } + }); + return newRow; + }); + + form.setValue("rows", updatedRows); + return true + } catch (err) { + toast.error("서브필드를 불러오는데 실패했습니다.") + setSubFields([]) + return false + } finally { + setIsLoadingSubFields(false) + } + } + + // --------------- + // Handle class selection + // --------------- + async function handleSelectClass(classOption: UpdatedClassOption) { + form.setValue("class", classOption.label) + if (classOption.tagTypeCode) { + setSelectedTagTypeCode(classOption.tagTypeCode) + // If you have tagTypeList, you can find the label + const tagType = tagTypeList.find(t => t.id === classOption.tagTypeCode) + if (tagType) { + form.setValue("tagType", tagType.label) + } else if (classOption.tagTypeDescription) { + form.setValue("tagType", classOption.tagTypeDescription) + } + await loadSubFieldsByTagTypeCode(classOption.tagTypeCode) + } + } + + // --------------- + // Build TagNo from subfields automatically for each row + // --------------- + React.useEffect(() => { + if (subFields.length === 0) { + return; + } + + const subscription = form.watch((value) => { + if (!value.rows || subFields.length === 0) { + return; + } + + const rows = [...value.rows]; + rows.forEach((row, rowIndex) => { + if (!row) return; + + let combined = ""; + subFields.forEach((sf, idx) => { + const fieldValue = row[sf.name] || ""; + combined += fieldValue; + if (fieldValue && idx < subFields.length - 1 && sf.delimiter) { + combined += sf.delimiter; + } + }); + + const currentTagNo = form.getValues(`rows.${rowIndex}.tagNo`); + if (currentTagNo !== combined) { + form.setValue(`rows.${rowIndex}.tagNo`, combined, { + shouldDirty: true, // Changed from false to true + shouldTouch: true, // Changed from false to true + shouldValidate: true, // Changed from false to true + }); + } + }); + }); + + return () => subscription.unsubscribe(); + }, [subFields, form]); + // --------------- + // Check if tag numbers are valid + // --------------- + const areAllTagNosValid = React.useMemo(() => { + const rows = form.getValues("rows"); + return rows.every(row => { + const tagNo = row.tagNo; + return tagNo && tagNo.trim() !== "" && !tagNo.includes("??"); + }); + }, [form.watch()]); // Watch the entire form to catch all changes + // --------------- + // Submit handler for multiple tags + // --------------- + async function onSubmit(data: MultiTagFormValues) { + if (!selectedPackageId) { + toast.error("No selectedPackageId."); + return; + } + + setIsSubmitting(true); + try { + const successfulTags = []; + const failedTags = []; + + // Process each row + for (const row of data.rows) { + // Create tag data from the row and shared class/tagType + const tagData: CreateTagSchema = { + tagType: data.tagType, + class: data.class, + tagNo: row.tagNo, + description: row.description, + ...Object.fromEntries( + subFields.map(field => [field.name, row[field.name] || ""]) + ), + // Add any required default fields from the original form + functionCode: row.functionCode || "", + seqNumber: row.seqNumber || "", + valveAcronym: row.valveAcronym || "", + processUnit: row.processUnit || "", + }; + + try { + const res = await createTag(tagData, selectedPackageId); + if ("error" in res) { + failedTags.push({ tag: row.tagNo, error: res.error }); + } else { + successfulTags.push(row.tagNo); + } + } catch (err) { + failedTags.push({ tag: row.tagNo, error: "Unknown error" }); + } + } + + // Show results to the user + if (successfulTags.length > 0) { + toast.success(`${successfulTags.length}개의 태그가 성공적으로 생성되었습니다!`); + } + + if (failedTags.length > 0) { + toast.error(`${failedTags.length}개의 태그 생성에 실패했습니다.`); + console.error("Failed tags:", failedTags); + } + + // Refresh the page + router.refresh(); + + // Reset the form and close dialog if all successful + if (failedTags.length === 0) { + form.reset(); + setOpen(false); + } + } catch (err) { + toast.error("태그 생성 처리에 실패했습니다."); + } finally { + setIsSubmitting(false); + } + } + + // --------------- + // Add a new row + // --------------- + function addRow() { + // Create a properly typed row with index signature to allow dynamic properties + const newRow: { + tagNo: string; + description: string; + [key: string]: string; // This allows any string key with string values + } = { + tagNo: "", + description: "" + }; + + // Add all subfields with empty values + subFields.forEach(field => { + newRow[field.name] = ""; + }); + + append(newRow); + + // Force form validation after row is added + setTimeout(() => form.trigger(), 0); + } + + // --------------- + // Duplicate row + // --------------- + function duplicateRow(index: number) { + const rowToDuplicate = form.getValues(`rows.${index}`); + // Use proper typing with index signature + const newRow: { + tagNo: string; + description: string; + [key: string]: string; + } = { ...rowToDuplicate }; + + // Clear the tagNo field as it will be auto-generated + newRow.tagNo = ""; + append(newRow); + + // Force form validation after row is duplicated + setTimeout(() => form.trigger(), 0); + } + + // --------------- + // Render Class field + // --------------- + function renderClassField(field: any) { + const [popoverOpen, setPopoverOpen] = React.useState(false) + + const buttonId = React.useMemo( + () => `class-button-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, + [] + ) + const popoverContentId = React.useMemo( + () => `class-popover-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, + [] + ) + const commandId = React.useMemo( + () => `class-command-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, + [] + ) + + return ( + <FormItem className="w-1/2"> + <FormLabel>Class</FormLabel> + <FormControl> + <Popover open={popoverOpen} onOpenChange={setPopoverOpen}> + <PopoverTrigger asChild> + <Button + key={buttonId} + type="button" + variant="outline" + className="w-full justify-between relative h-9" + disabled={isLoadingClasses} + > + {isLoadingClasses ? ( + <> + <span>클래스 로딩 중...</span> + <Loader2 className="ml-2 h-4 w-4 animate-spin" /> + </> + ) : ( + <> + <span className="truncate mr-1 flex-grow text-left"> + {field.value || "클래스 선택..."} + </span> + <ChevronsUpDown className="h-4 w-4 opacity-50 flex-shrink-0" /> + </> + )} + </Button> + </PopoverTrigger> + <PopoverContent key={popoverContentId} className="w-[300px] p-0"> + <Command key={commandId}> + <CommandInput + key={`${commandId}-input`} + placeholder="클래스 검색..." + value={classSearchTerm} + onValueChange={setClassSearchTerm} + /> + <CommandList key={`${commandId}-list`} className="max-h-[300px]"> + <CommandEmpty key={`${commandId}-empty`}>검색 결과가 없습니다.</CommandEmpty> + <CommandGroup key={`${commandId}-group`}> + {classOptions.map((opt, optIndex) => { + if (!classOptionIdsRef.current[opt.code]) { + classOptionIdsRef.current[opt.code] = + `class-${opt.code}-${Date.now()}-${Math.random() + .toString(36) + .slice(2, 9)}` + } + const optionId = classOptionIdsRef.current[opt.code] + + return ( + <CommandItem + key={`${optionId}-${optIndex}`} + onSelect={() => { + field.onChange(opt.label) + setPopoverOpen(false) + handleSelectClass(opt) + }} + value={opt.label} + className="truncate" + title={opt.label} + > + <span className="truncate">{opt.label}</span> + <Check + key={`${optionId}-check`} + className={cn( + "ml-auto h-4 w-4 flex-shrink-0", + field.value === opt.label ? "opacity-100" : "opacity-0" + )} + /> + </CommandItem> + ) + })} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + </FormControl> + <FormMessage /> + </FormItem> + ) + } + + // --------------- + // Render TagType field (readonly after class selection) + // --------------- + function renderTagTypeField(field: any) { + const isReadOnly = !!selectedTagTypeCode + const inputId = React.useMemo( + () => + `tag-type-input-${isReadOnly ? "readonly" : "editable"}-${Date.now()}-${Math.random() + .toString(36) + .slice(2, 9)}`, + [isReadOnly] + ) + + return ( + <FormItem className="w-1/2"> + <FormLabel>Tag Type</FormLabel> + <FormControl> + {isReadOnly ? ( + <div className="relative"> + <Input + key={`tag-type-readonly-${inputId}`} + {...field} + readOnly + className="h-9 bg-muted" + /> + </div> + ) : ( + <Input + key={`tag-type-placeholder-${inputId}`} + {...field} + readOnly + placeholder="클래스 선택시 자동으로 결정됩니다" + className="h-9 bg-muted" + /> + )} + </FormControl> + <FormMessage /> + </FormItem> + ) + } + + // --------------- + // Render the table of subfields + // --------------- + function renderTagTable() { + if (isLoadingSubFields) { + return ( + <div className="flex justify-center items-center py-8"> + <Loader2 className="h-8 w-8 animate-spin text-primary" /> + <div className="ml-3 text-muted-foreground">필드 로딩 중...</div> + </div> + ) + } + + if (subFields.length === 0 && selectedTagTypeCode) { + return ( + <div className="py-4 text-center text-muted-foreground"> + 이 태그 유형에 대한 필드가 없습니다. + </div> + ) + } + + if (subFields.length === 0) { + return ( + <div className="py-4 text-center text-muted-foreground"> + 태그 데이터를 입력하려면 먼저 상단에서 클래스를 선택하세요. + </div> + ) + } + + return ( + <div className="space-y-4"> + {/* 헤더 */} + <div className="flex justify-between items-center"> + <h3 className="text-sm font-medium">태그 항목 ({fields.length}개)</h3> + {!areAllTagNosValid && ( + <Badge variant="destructive" className="ml-2"> + 유효하지 않은 태그 존재 + </Badge> + )} + </div> + + {/* 테이블 컨테이너 - 가로/세로 스크롤 모두 적용 */} + <div className="border rounded-md overflow-auto" style={{ maxHeight: '400px', maxWidth: '100%' }}> + <div className="min-w-full overflow-x-auto"> + <Table className="w-full table-fixed"> + <TableHeader className="sticky top-0 bg-muted z-10"> + <TableRow> + <TableHead className="w-10 text-center">#</TableHead> + <TableHead className="w-[120px]"> + <div className="font-medium">Tag No</div> + </TableHead> + <TableHead className="w-[180px]"> + <div className="font-medium">Description</div> + </TableHead> + + {/* Subfields */} + {subFields.map((field, fieldIndex) => ( + <TableHead + key={`header-${field.name}-${fieldIndex}`} + className="w-[120px]" + > + <div className="flex flex-col"> + <div className="font-medium" title={field.label}> + {field.label} + </div> + {field.expression && ( + <div className="text-[10px] text-muted-foreground truncate" title={field.expression}> + {field.expression} + </div> + )} + </div> + </TableHead> + ))} + + <TableHead className="w-[100px] text-center sticky right-0 bg-muted">Actions</TableHead> + </TableRow> + </TableHeader> + + <TableBody> + {fields.map((item, rowIndex) => ( + <TableRow + key={`row-${item.id}-${rowIndex}`} + className={rowIndex % 2 === 0 ? "bg-background" : "bg-muted/20"} + > + {/* Row number */} + <TableCell className="text-center text-muted-foreground font-mono"> + {rowIndex + 1} + </TableCell> + + {/* Tag No cell */} + <TableCell className="p-1"> + <FormField + control={form.control} + name={`rows.${rowIndex}.tagNo`} + render={({ field }) => ( + <FormItem className="m-0 space-y-0"> + <FormControl> + <div className="relative"> + <Input + {...field} + readOnly + className={cn( + "bg-muted h-8 w-full font-mono text-sm", + field.value?.includes("??") && "border-red-500 bg-red-50" + )} + title={field.value || ""} + /> + {field.value?.includes("??") && ( + <div className="absolute right-2 top-1/2 transform -translate-y-1/2"> + <Badge variant="destructive" className="text-xs"> + ! + </Badge> + </div> + )} + </div> + </FormControl> + </FormItem> + )} + /> + </TableCell> + + {/* Description cell */} + <TableCell className="p-1"> + <FormField + control={form.control} + name={`rows.${rowIndex}.description`} + render={({ field }) => ( + <FormItem className="m-0 space-y-0"> + <FormControl> + <Input + {...field} + className="h-8 w-full" + placeholder="항목 이름 입력" + title={field.value || ""} + /> + </FormControl> + </FormItem> + )} + /> + </TableCell> + + {/* Subfield cells */} + {subFields.map((sf, sfIndex) => ( + <TableCell + key={`cell-${item.id}-${rowIndex}-${sf.name}-${sfIndex}`} + className="p-1" + > + <FormField + control={form.control} + name={`rows.${rowIndex}.${sf.name}`} + render={({ field }) => ( + <FormItem className="m-0 space-y-0"> + <FormControl> + {sf.type === "select" ? ( + <Select + value={field.value || ""} + onValueChange={field.onChange} + > + <SelectTrigger + className="w-full h-8 truncate" + title={field.value || ""} + > + <SelectValue placeholder={`선택...`} className="truncate" /> + </SelectTrigger> + <SelectContent + align="start" + side="bottom" + className="max-h-[200px]" + style={{ minWidth: "250px", maxWidth: "350px" }} + > + {sf.options?.map((opt, index) => ( + <SelectItem + key={`${rowIndex}-${sf.name}-${opt.value}-${index}`} + value={opt.value} + title={opt.label} + className="whitespace-normal py-2 break-words" + > + {opt.label} + </SelectItem> + ))} + </SelectContent> + </Select> + ) : ( + <Input + {...field} + className="h-8 w-full" + placeholder={`입력...`} + title={field.value || ""} + /> + )} + </FormControl> + {/* <FormMessage>{sf.expression}</FormMessage> */} + </FormItem> + + )} + /> + </TableCell> + ))} + + {/* Actions cell */} + <TableCell className="p-1 sticky right-0 bg-white shadow-[-4px_0_4px_rgba(0,0,0,0.05)]"> + <div className="flex justify-center space-x-1"> + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button + type="button" + variant="ghost" + size="icon" + className="h-7 w-7" + onClick={() => duplicateRow(rowIndex)} + > + <Copy className="h-3.5 w-3.5 text-muted-foreground" /> + </Button> + </TooltipTrigger> + <TooltipContent side="left"> + <p>행 복제</p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Button + type="button" + variant="ghost" + size="icon" + className={cn( + "h-7 w-7", + fields.length <= 1 && "opacity-50" + )} + onClick={() => fields.length > 1 && remove(rowIndex)} + disabled={fields.length <= 1} + > + <Trash2 className="h-3.5 w-3.5 text-red-500" /> + </Button> + </TooltipTrigger> + <TooltipContent side="left"> + <p>행 삭제</p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + </div> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </div> + + {/* 행 추가 버튼 */} + <Button + type="button" + variant="outline" + className="w-full border-dashed" + onClick={addRow} + disabled={!selectedTagTypeCode || isLoadingSubFields} + > + <Plus className="h-4 w-4 mr-2" /> + 새 행 추가 + </Button> + </div> + </div> + ); + } + + // --------------- + // Reset IDs/states when dialog closes + // --------------- + React.useEffect(() => { + if (!open) { + fieldIdsRef.current = {} + classOptionIdsRef.current = {} + selectIdRef.current = 0 + } + }, [open]) + + return ( + <Dialog + open={open} + onOpenChange={(o) => { + if (!o) { + form.reset({ + tagType: "", + class: "", + rows: [{ tagNo: "", description: "" }] + }); + setSelectedTagTypeCode(null); + setSubFields([]); + } + setOpen(o); + }} + > + <DialogTrigger asChild> + <Button variant="default" size="sm"> + 태그 추가 + </Button> + </DialogTrigger> + + <DialogContent className="max-h-[90vh] max-w-[95vw]" style={{ width: 1500 }}> + <DialogHeader> + <DialogTitle>새 태그 추가</DialogTitle> + <DialogDescription> + 클래스를 선택하여 태그 유형과 하위 필드를 로드한 다음, 여러 행을 추가하여 여러 태그를 생성하세요. + </DialogDescription> + </DialogHeader> + + <Form {...form}> + <form + onSubmit={form.handleSubmit(onSubmit)} + className="space-y-6" + > + {/* 클래스 및 태그 유형 선택 */} + <div className="flex gap-4"> + <FormField + key="class-field" + control={form.control} + name="class" + render={({ field }) => renderClassField(field)} + /> + + <FormField + key="tag-type-field" + control={form.control} + name="tagType" + render={({ field }) => renderTagTypeField(field)} + /> + </div> + + {/* 태그 테이블 */} + {renderTagTable()} + + {/* 버튼 */} + <DialogFooter> + <div className="flex items-center gap-2"> + <Button + type="button" + variant="outline" + onClick={() => { + form.reset({ + tagType: "", + class: "", + rows: [{ tagNo: "", description: "" }] + }); + setOpen(false); + setSubFields([]); + setSelectedTagTypeCode(null); + }} + disabled={isSubmitting} + > + 취소 + </Button> + <Button + type="submit" + disabled={isSubmitting || !areAllTagNosValid || fields.length < 1} + > + {isSubmitting ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + 처리 중... + </> + ) : ( + `${fields.length}개 태그 생성` + )} + </Button> + </div> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/tags/table/delete-tags-dialog.tsx b/lib/tags/table/delete-tags-dialog.tsx new file mode 100644 index 00000000..6a024cda --- /dev/null +++ b/lib/tags/table/delete-tags-dialog.tsx @@ -0,0 +1,151 @@ +"use client" + +import * as React from "react" +import { type Row } from "@tanstack/react-table" +import { Loader, Trash } from "lucide-react" +import { toast } from "sonner" + +import { useMediaQuery } from "@/hooks/use-media-query" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer" + +import { removeTags } from "@/lib//tags/service" +import { Tag } from "@/db/schema/vendorData" + +interface DeleteTasksDialogProps + extends React.ComponentPropsWithoutRef<typeof Dialog> { + tags: Row<Tag>["original"][] + showTrigger?: boolean + selectedPackageId: number + onSuccess?: () => void +} + +export function DeleteTagsDialog({ + tags, + showTrigger = true, + onSuccess, + selectedPackageId, + ...props +}: DeleteTasksDialogProps) { + const [isDeletePending, startDeleteTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + + function onDelete() { + startDeleteTransition(async () => { + const { error } = await removeTags({ + ids: tags.map((tag) => tag.id),selectedPackageId + }) + + if (error) { + toast.error(error) + return + } + + props.onOpenChange?.(false) + toast.success("Tasks deleted") + onSuccess?.() + }) + } + + if (isDesktop) { + return ( + <Dialog {...props}> + {showTrigger ? ( + <DialogTrigger asChild> + <Button variant="outline" size="sm"> + <Trash className="size-4" aria-hidden="true" /> + Delete ({tags.length}) + </Button> + </DialogTrigger> + ) : null} + <DialogContent> + <DialogHeader> + <DialogTitle>Are you absolutely sure?</DialogTitle> + <DialogDescription> + This action cannot be undone. This will permanently delete your{" "} + <span className="font-medium">{tags.length}</span> + {tags.length === 1 ? " tag" : " tags"} from our servers. + </DialogDescription> + </DialogHeader> + <DialogFooter className="gap-2 sm:space-x-0"> + <DialogClose asChild> + <Button variant="outline">Cancel</Button> + </DialogClose> + <Button + aria-label="Delete selected rows" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending} + > + {isDeletePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + Delete + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) + } + + return ( + <Drawer {...props}> + {showTrigger ? ( + <DrawerTrigger asChild> + <Button variant="outline" size="sm"> + <Trash className="mr-2 size-4" aria-hidden="true" /> + Delete ({tags.length}) + </Button> + </DrawerTrigger> + ) : null} + <DrawerContent> + <DrawerHeader> + <DrawerTitle>Are you absolutely sure?</DrawerTitle> + <DrawerDescription> + This action cannot be undone. This will permanently delete your{" "} + <span className="font-medium">{tags.length}</span> + {tags.length === 1 ? " tag" : " tags"} from our servers. + </DrawerDescription> + </DrawerHeader> + <DrawerFooter className="gap-2 sm:space-x-0"> + <DrawerClose asChild> + <Button variant="outline">Cancel</Button> + </DrawerClose> + <Button + aria-label="Delete selected rows" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending} + > + {isDeletePending && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + Delete + </Button> + </DrawerFooter> + </DrawerContent> + </Drawer> + ) +} diff --git a/lib/tags/table/feature-flags-provider.tsx b/lib/tags/table/feature-flags-provider.tsx new file mode 100644 index 00000000..81131894 --- /dev/null +++ b/lib/tags/table/feature-flags-provider.tsx @@ -0,0 +1,108 @@ +"use client" + +import * as React from "react" +import { useQueryState } from "nuqs" + +import { dataTableConfig, type DataTableConfig } from "@/config/data-table" +import { cn } from "@/lib/utils" +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" + +type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"] + +interface FeatureFlagsContextProps { + featureFlags: FeatureFlagValue[] + setFeatureFlags: (value: FeatureFlagValue[]) => void +} + +const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({ + featureFlags: [], + setFeatureFlags: () => {}, +}) + +export function useFeatureFlags() { + const context = React.useContext(FeatureFlagsContext) + if (!context) { + throw new Error( + "useFeatureFlags must be used within a FeatureFlagsProvider" + ) + } + return context +} + +interface FeatureFlagsProviderProps { + children: React.ReactNode +} + +export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) { + const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>( + "flags", + { + defaultValue: [], + parse: (value) => value.split(",") as FeatureFlagValue[], + serialize: (value) => value.join(","), + eq: (a, b) => + a.length === b.length && a.every((value, index) => value === b[index]), + clearOnDefault: true, + shallow: false, + } + ) + + return ( + <FeatureFlagsContext.Provider + value={{ + featureFlags, + setFeatureFlags: (value) => void setFeatureFlags(value), + }} + > + <div className="w-full overflow-x-auto"> + <ToggleGroup + type="multiple" + variant="outline" + size="sm" + value={featureFlags} + onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)} + className="w-fit gap-0" + > + {dataTableConfig.featureFlags.map((flag, index) => ( + <Tooltip key={flag.value}> + <ToggleGroupItem + value={flag.value} + className={cn( + "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90", + { + "rounded-l-sm border-r-0": index === 0, + "rounded-r-sm": + index === dataTableConfig.featureFlags.length - 1, + } + )} + asChild + > + <TooltipTrigger> + <flag.icon className="size-3.5 shrink-0" aria-hidden="true" /> + {flag.label} + </TooltipTrigger> + </ToggleGroupItem> + <TooltipContent + align="start" + side="bottom" + sideOffset={6} + className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground" + > + <div>{flag.tooltipTitle}</div> + <div className="text-xs text-muted-foreground"> + {flag.tooltipDescription} + </div> + </TooltipContent> + </Tooltip> + ))} + </ToggleGroup> + </div> + {children} + </FeatureFlagsContext.Provider> + ) +} diff --git a/lib/tags/table/tag-table-column.tsx b/lib/tags/table/tag-table-column.tsx new file mode 100644 index 00000000..47746000 --- /dev/null +++ b/lib/tags/table/tag-table-column.tsx @@ -0,0 +1,164 @@ +"use client" + +import * as React from "react" +import { ColumnDef } from "@tanstack/react-table" +import { formatDate } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Ellipsis } from "lucide-react" +// 기존 헤더 컴포넌트 사용 (리사이저가 내장된 헤더는 따로 구현할 예정) +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { Tag } from "@/db/schema/vendorData" +import { DataTableRowAction } from "@/types/table" + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<Tag> | null>> +} + +export function getColumns({ + setRowAction, +}: GetColumnsProps): ColumnDef<Tag>[] { + return [ + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + enableSorting: false, + enableHiding: false, + enableResizing: false, // 체크박스 열은 리사이징 비활성화 + size: 40, + minSize: 40, + maxSize: 40, + }, + + { + accessorKey: "tagNo", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Tag No." /> + ), + cell: ({ row }) => <div>{row.getValue("tagNo")}</div>, + meta: { + excelHeader: "Tag No" + }, + enableResizing: true, // 리사이징 활성화 + minSize: 100, // 최소 너비 + size: 160, // 기본 너비 + }, + { + accessorKey: "description", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Tag Description" /> + ), + cell: ({ row }) => <div>{row.getValue("description")}</div>, + meta: { + excelHeader: "Tag Descripiton" + }, + enableResizing: true, + minSize: 150, + size: 240, + }, + { + accessorKey: "class", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Tag Class" /> + ), + cell: ({ row }) => <div>{row.getValue("class")}</div>, + meta: { + excelHeader: "Tag Class" + }, + enableResizing: true, + minSize: 100, + size: 150, + }, + { + accessorKey: "createdAt", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Created At" /> + ), + cell: ({ cell }) => formatDate(cell.getValue() as Date), + meta: { + excelHeader: "created At" + }, + enableResizing: true, + minSize: 120, + size: 180, + }, + { + accessorKey: "updatedAt", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Updated At" /> + ), + cell: ({ cell }) => formatDate(cell.getValue() as Date), + meta: { + excelHeader: "updated At" + }, + enableResizing: true, + minSize: 120, + size: 180, + }, + { + id: "actions", + cell: function Cell({ row }) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-8 p-0 data-[state=open]:bg-muted" + > + <Ellipsis className="size-6" aria-hidden="true" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-40"> + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "update" })} + > + Edit + </DropdownMenuItem> + <DropdownMenuSeparator /> + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "delete" })} + > + Delete + <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ) + }, + enableResizing: false, // 액션 열은 리사이징 비활성화 + size: 40, + minSize: 40, + maxSize: 40, + enableHiding: false, + }, + ] +}
\ No newline at end of file diff --git a/lib/tags/table/tag-table.tsx b/lib/tags/table/tag-table.tsx new file mode 100644 index 00000000..5c8c048f --- /dev/null +++ b/lib/tags/table/tag-table.tsx @@ -0,0 +1,141 @@ +"use client" + +import * as React from "react" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" + +import { toSentenceCase } from "@/lib/utils" +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" + +import { getColumns } from "./tag-table-column" +import { Tag } from "@/db/schema/vendorData" +import { DeleteTagsDialog } from "./delete-tags-dialog" +import { TagsTableToolbarActions } from "./tags-table-toolbar-actions" +import { TagsTableFloatingBar } from "./tags-table-floating-bar" +import { getTags } from "../service" +import { UpdateTagSheet } from "./update-tag-sheet" + +// 여기서 받은 `promises`로부터 태그 목록을 가져와 상태를 세팅 +// 예: "selectedPackageId"는 props로 전달 +interface TagsTableProps { + promises: Promise< [ Awaited<ReturnType<typeof getTags>> ] > + selectedPackageId: number +} + +export function TagsTable({ promises, selectedPackageId }: TagsTableProps) { + // 1) 데이터를 가져옴 (server component -> use(...) pattern) + const [{ data, pageCount }] = React.use(promises) + + + + + const [rowAction, setRowAction] = React.useState<DataTableRowAction<Tag> | null>(null) + + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + // Filter fields + const filterFields: DataTableFilterField<Tag>[] = [ + { + id: "tagNo", + label: "Tag Number", + placeholder: "Filter Tag Number...", + }, + ] + + const advancedFilterFields: DataTableAdvancedFilterField<Tag>[] = [ + { + id: "tagNo", + label: "Tag No", + type: "text", + }, + { + id: "tagType", + label: "Tag Type", + type: "text", + }, + { + id: "description", + label: "Description", + type: "text", + }, + { + id: "createdAt", + label: "Created at", + type: "date", + }, + { + id: "updatedAt", + label: "Updated at", + type: "date", + }, + ] + + // 3) useDataTable 훅으로 react-table 구성 + const { table } = useDataTable({ + data: data, // <-- 여기서 tableData 사용 + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + columnResizeMode: "onEnd", + + }) + + return ( + <> + <DataTable + table={table} + floatingBar={<TagsTableFloatingBar table={table} selectedPackageId={selectedPackageId}/>} + > + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + {/* + 4) ToolbarActions에 tableData, setTableData 넘겨서 + import 시 상태 병합 + */} + <TagsTableToolbarActions + table={table} + selectedPackageId={selectedPackageId} + tableData={data} // <-- pass current data + /> + </DataTableAdvancedToolbar> + </DataTable> + + <UpdateTagSheet + open={rowAction?.type === "update"} + onOpenChange={() => setRowAction(null)} + tag={rowAction?.row.original ?? null} + selectedPackageId={selectedPackageId} + /> + + + <DeleteTagsDialog + open={rowAction?.type === "delete"} + onOpenChange={() => setRowAction(null)} + tags={rowAction?.row.original ? [rowAction?.row.original] : []} + showTrigger={false} + onSuccess={() => rowAction?.row.toggleSelected(false)} + selectedPackageId={selectedPackageId} + /> + </> + ) +}
\ No newline at end of file diff --git a/lib/tags/table/tags-export.tsx b/lib/tags/table/tags-export.tsx new file mode 100644 index 00000000..4afbac6c --- /dev/null +++ b/lib/tags/table/tags-export.tsx @@ -0,0 +1,155 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { toast } from "sonner" +import ExcelJS from "exceljs" +import { saveAs } from "file-saver" +import { Tag } from "@/db/schema/vendorData" +import { getClassOptions } from "../service" + +/** + * 태그 데이터를 엑셀로 내보내는 함수 (유효성 검사 포함) + * - 별도의 ValidationData 시트에 Tag Class 옵션 데이터를 포함 + * - Tag Class 열에 데이터 유효성 검사(드롭다운)을 적용 + */ +export async function exportTagsToExcel( + table: Table<Tag>, + { + filename = "Tags", + excludeColumns = ["select", "actions", "createdAt", "updatedAt"], + maxRows = 5000, // 데이터 유효성 검사를 적용할 최대 행 수 + }: { + filename?: string + excludeColumns?: string[] + maxRows?: number + } = {} +) { + try { + // 1. 테이블에서 컬럼 정보 가져오기 + const allTableColumns = table.getAllLeafColumns() + + // 제외할 컬럼 필터링 + const tableColumns = allTableColumns.filter( + (col) => !excludeColumns.includes(col.id) + ) + + // 2. 워크북 및 워크시트 생성 + const workbook = new ExcelJS.Workbook() + const worksheet = workbook.addWorksheet("Tags") + + // 3. Tag Class 옵션 가져오기 + const classOptions = await getClassOptions() + + // 4. 유효성 검사 시트 생성 + const validationSheet = workbook.addWorksheet("ValidationData") + validationSheet.state = 'hidden' // 시트 숨김 처리 + + // 4.1. Tag Class 유효성 검사 데이터 추가 + validationSheet.getColumn(1).values = ["Tag Class", ...classOptions.map(opt => opt.label)] + + // 5. 메인 시트에 헤더 추가 + const headers = tableColumns.map((col) => { + const meta = col.columnDef.meta as any + // meta에 excelHeader가 있으면 사용 + if (meta?.excelHeader) { + return meta.excelHeader + } + // 없으면 컬럼 ID 사용 + return col.id + }) + + worksheet.addRow(headers) + + // 6. 헤더 스타일 적용 + const headerRow = worksheet.getRow(1) + headerRow.font = { bold: true } + headerRow.alignment = { horizontal: 'center' } + headerRow.eachCell((cell) => { + cell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFCCCCCC' } + } + }) + + // 7. 데이터 행 추가 + const rowModel = table.getPrePaginationRowModel() + + rowModel.rows.forEach((row) => { + const rowData = tableColumns.map((col) => { + const value = row.getValue(col.id) + + // 날짜 형식 처리 + if (value instanceof Date) { + return new Date(value).toISOString().split('T')[0] + } + + // value가 null/undefined면 빈 문자열, 객체면 JSON 문자열, 그 외에는 그대로 반환 + if (value == null) return "" + return typeof value === "object" ? JSON.stringify(value) : value + }) + + worksheet.addRow(rowData) + }) + + // 8. Tag Class 열에 데이터 유효성 검사 적용 + const classColIndex = headers.findIndex(header => header === "Tag Class") + + if (classColIndex !== -1) { + const colLetter = worksheet.getColumn(classColIndex + 1).letter + + // 데이터 유효성 검사 설정 + const validation = { + type: 'list' as const, + allowBlank: true, + formulae: [`ValidationData!$A$2:$A$${classOptions.length + 1}`], + showErrorMessage: true, + errorStyle: 'warning' as const, + errorTitle: '유효하지 않은 클래스', + error: '목록에서 클래스를 선택해주세요.' + } + + // 모든 데이터 행 + 추가 행(최대 maxRows까지)에 유효성 검사 적용 + for (let rowIdx = 2; rowIdx <= maxRows; rowIdx++) { + worksheet.getCell(`${colLetter}${rowIdx}`).dataValidation = validation + } + } + + // 9. 컬럼 너비 자동 조정 + tableColumns.forEach((col, index) => { + const column = worksheet.getColumn(index + 1) + const headerLength = headers[index]?.length || 10 + + // 데이터 기반 최대 길이 계산 + let maxLength = headerLength + rowModel.rows.forEach((row) => { + const value = row.getValue(col.id) + if (value != null) { + const valueLength = String(value).length + if (valueLength > maxLength) { + maxLength = valueLength + } + } + }) + + // 너비 설정 (최소 10, 최대 50) + column.width = Math.min(Math.max(maxLength + 2, 10), 50) + }) + + // 10. 파일 다운로드 + const buffer = await workbook.xlsx.writeBuffer() + saveAs( + new Blob([buffer], { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }), + `${filename}_${new Date().toISOString().split('T')[0]}.xlsx` + ) + + return true + } catch (error) { + console.error("Excel export error:", error) + toast.error("Excel 내보내기 중 오류가 발생했습니다.") + return false + } +}
\ No newline at end of file diff --git a/lib/tags/table/tags-table-floating-bar.tsx b/lib/tags/table/tags-table-floating-bar.tsx new file mode 100644 index 00000000..8d55b7ac --- /dev/null +++ b/lib/tags/table/tags-table-floating-bar.tsx @@ -0,0 +1,220 @@ +"use client" + +import * as React from "react" +import { SelectTrigger } from "@radix-ui/react-select" +import { type Table } from "@tanstack/react-table" +import { + ArrowUp, + CheckCircle2, + Download, + Loader, + Trash2, + X, +} from "lucide-react" +import { toast } from "sonner" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" +import { Portal } from "@/components/ui/portal" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, +} from "@/components/ui/select" +import { Separator } from "@/components/ui/separator" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" +import { Kbd } from "@/components/kbd" + +import { removeTags } from "@/lib//tags/service" +import { ActionConfirmDialog } from "@/components/ui/action-dialog" +import { Tag } from "@/db/schema/vendorData" + +interface TagsTableFloatingBarProps { + table: Table<Tag> + selectedPackageId: number + +} + + +export function TagsTableFloatingBar({ table, selectedPackageId }: TagsTableFloatingBarProps) { + const rows = table.getFilteredSelectedRowModel().rows + + const [isPending, startTransition] = React.useTransition() + const [action, setAction] = React.useState< + "update-status" | "update-priority" | "export" | "delete" + >() + const [popoverOpen, setPopoverOpen] = React.useState(false) + + // Clear selection on Escape key press + React.useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + table.toggleAllRowsSelected(false) + } + } + + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [table]) + + + + // 공용 confirm dialog state + const [confirmDialogOpen, setConfirmDialogOpen] = React.useState(false) + const [confirmProps, setConfirmProps] = React.useState<{ + title: string + description?: string + onConfirm: () => Promise<void> | void + }>({ + title: "", + description: "", + onConfirm: () => { }, + }) + + // 1) "삭제" Confirm 열기 + function handleDeleteConfirm() { + setAction("delete") + setConfirmProps({ + title: `Delete ${rows.length} tag${rows.length > 1 ? "s" : ""}?`, + description: "This action cannot be undone.", + onConfirm: async () => { + startTransition(async () => { + const { error } = await removeTags({ + ids: rows.map((row) => row.original.id), + selectedPackageId + }) + if (error) { + toast.error(error) + return + } + toast.success("Tags deleted") + table.toggleAllRowsSelected(false) + setConfirmDialogOpen(false) + }) + }, + }) + setConfirmDialogOpen(true) + } + + + + return ( + <Portal > + <div className="fixed inset-x-0 bottom-10 z-50 mx-auto w-fit px-2.5" style={{ bottom: '1.5rem' }}> + <div className="w-full overflow-x-auto"> + <div className="mx-auto flex w-fit items-center gap-2 rounded-md border bg-background p-2 text-foreground shadow"> + <div className="flex h-7 items-center rounded-md border border-dashed pl-2.5 pr-1"> + <span className="whitespace-nowrap text-xs"> + {rows.length} selected + </span> + <Separator orientation="vertical" className="ml-2 mr-1" /> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="icon" + className="size-5 hover:border" + onClick={() => table.toggleAllRowsSelected(false)} + > + <X className="size-3.5 shrink-0" aria-hidden="true" /> + </Button> + </TooltipTrigger> + <TooltipContent className="flex items-center border bg-accent px-2 py-1 font-semibold text-foreground dark:bg-zinc-900"> + <p className="mr-2">Clear selection</p> + <Kbd abbrTitle="Escape" variant="outline"> + Esc + </Kbd> + </TooltipContent> + </Tooltip> + </div> + <Separator orientation="vertical" className="hidden h-5 sm:block" /> + <div className="flex items-center gap-1.5"> + + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="secondary" + size="icon" + className="size-7 border" + onClick={() => { + setAction("export") + + startTransition(() => { + exportTableToExcel(table, { + excludeColumns: ["select", "actions"], + onlySelected: true, + }) + }) + }} + disabled={isPending} + > + {isPending && action === "export" ? ( + <Loader + className="size-3.5 animate-spin" + aria-hidden="true" + /> + ) : ( + <Download className="size-3.5" aria-hidden="true" /> + )} + </Button> + </TooltipTrigger> + <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> + <p>Export tasks</p> + </TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="secondary" + size="icon" + className="size-7 border" + onClick={handleDeleteConfirm} + disabled={isPending} + > + {isPending && action === "delete" ? ( + <Loader + className="size-3.5 animate-spin" + aria-hidden="true" + /> + ) : ( + <Trash2 className="size-3.5" aria-hidden="true" /> + )} + </Button> + </TooltipTrigger> + <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> + <p>Delete tasks</p> + </TooltipContent> + </Tooltip> + </div> + </div> + </div> + </div> + + + {/* 공용 Confirm Dialog */} + <ActionConfirmDialog + open={confirmDialogOpen} + onOpenChange={setConfirmDialogOpen} + title={confirmProps.title} + description={confirmProps.description} + onConfirm={confirmProps.onConfirm} + isLoading={isPending && (action === "delete" || action === "update-priority" || action === "update-status")} + confirmLabel={ + action === "delete" + ? "Delete" + : action === "update-priority" || action === "update-status" + ? "Update" + : "Confirm" + } + confirmVariant={ + action === "delete" ? "destructive" : "default" + } + /> + </Portal> + ) +} diff --git a/lib/tags/table/tags-table-toolbar-actions.tsx b/lib/tags/table/tags-table-toolbar-actions.tsx new file mode 100644 index 00000000..8d53d3f3 --- /dev/null +++ b/lib/tags/table/tags-table-toolbar-actions.tsx @@ -0,0 +1,598 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { toast } from "sonner" +import ExcelJS from "exceljs" +import { saveAs } from "file-saver" + +import { Button } from "@/components/ui/button" +import { Download, Upload, Loader2 } from "lucide-react" +import { Tag, TagSubfields } from "@/db/schema/vendorData" +import { exportTagsToExcel } from "./tags-export" +import { AddTagDialog } from "./add-tag-dialog" +import { fetchTagSubfieldOptions, getTagNumberingRules, } from "@/lib/tag-numbering/service" +import { bulkCreateTags, getClassOptions, getSubfieldsByTagType } from "../service" +import { DeleteTagsDialog } from "./delete-tags-dialog" + +// 태그 번호 검증을 위한 인터페이스 +interface TagNumberingRule { + attributesId: string; + attributesDescription: string; + expression: string | null; + delimiter: string | null; + sortOrder: number; +} + +interface TagOption { + code: string; + label: string; +} + +interface ClassOption { + code: string; + label: string; + tagTypeCode: string; + tagTypeDescription: string; +} + +// 서브필드 정의 +interface SubFieldDef { + name: string; + label: string; + type: "select" | "text"; + options?: { value: string; label: string }[]; + expression?: string; + delimiter?: string; +} + +interface TagsTableToolbarActionsProps { + /** react-table 객체 */ + table: Table<Tag> + /** 현재 선택된 패키지 ID */ + selectedPackageId: number + /** 현재 태그 목록(상태) */ + tableData: Tag[] + /** 태그 목록을 갱신하는 setState */ +} + +/** + * TagsTableToolbarActions: + * - Import 버튼 -> Excel 파일 파싱 & 유효성 검사 (Class 기반 검증 추가) + * - 에러 발생 시: state는 그대로 두고, 오류가 적힌 엑셀만 재다운로드 + * - 정상인 경우: tableData에 병합 + * - Export 버튼 -> 유효성 검사가 포함된 Excel 내보내기 + */ +export function TagsTableToolbarActions({ + table, + selectedPackageId, + tableData, +}: TagsTableToolbarActionsProps) { + const [isPending, setIsPending] = React.useState(false) + const [isExporting, setIsExporting] = React.useState(false) + const fileInputRef = React.useRef<HTMLInputElement>(null) + + // 태그 타입별 넘버링 룰 캐시 + const [tagNumberingRules, setTagNumberingRules] = React.useState<Record<string, TagNumberingRule[]>>({}) + const [tagOptionsCache, setTagOptionsCache] = React.useState<Record<string, TagOption[]>>({}) + + // 클래스 옵션 및 서브필드 캐시 + const [classOptions, setClassOptions] = React.useState<ClassOption[]>([]) + const [subfieldCache, setSubfieldCache] = React.useState<Record<string, SubFieldDef[]>>({}) + + // 컴포넌트 마운트 시 클래스 옵션 로드 + React.useEffect(() => { + const loadClassOptions = async () => { + try { + const options = await getClassOptions() + setClassOptions(options) + } catch (error) { + console.error("Failed to load class options:", error) + } + } + + loadClassOptions() + }, []) + + // 숨겨진 <input>을 클릭 + function handleImportClick() { + fileInputRef.current?.click() + } + + // 태그 넘버링 룰 가져오기 + const fetchTagNumberingRules = React.useCallback(async (tagType: string): Promise<TagNumberingRule[]> => { + // 이미 캐시에 있으면 캐시된 값 사용 + if (tagNumberingRules[tagType]) { + return tagNumberingRules[tagType] + } + + try { + // 서버 액션 직접 호출 + const rules = await getTagNumberingRules(tagType) + + // 캐시에 저장 + setTagNumberingRules(prev => ({ + ...prev, + [tagType]: rules + })) + + return rules + } catch (error) { + console.error(`Error fetching rules for ${tagType}:`, error) + return [] + } + }, [tagNumberingRules]) + + // 특정 attributesId에 대한 옵션 가져오기 + const fetchOptions = React.useCallback(async (attributesId: string): Promise<TagOption[]> => { + // 이미 캐시에 있으면 캐시된 값 사용 + if (tagOptionsCache[attributesId]) { + return tagOptionsCache[attributesId] + } + + try { + const options = await fetchTagSubfieldOptions(attributesId) + + // 캐시에 저장 + setTagOptionsCache(prev => ({ + ...prev, + [attributesId]: options + })) + + return options + } catch (error) { + console.error(`Error fetching options for ${attributesId}:`, error) + return [] + } + }, [tagOptionsCache]) + + // 클래스 라벨로 태그 타입 코드 찾기 + const getTagTypeCodeByClassLabel = React.useCallback((classLabel: string): string | null => { + const classOption = classOptions.find(opt => opt.label === classLabel) + return classOption?.tagTypeCode || null + }, [classOptions]) + + // 태그 타입에 따른 서브필드 가져오기 + const fetchSubfieldsByTagType = React.useCallback(async (tagTypeCode: string): Promise<SubFieldDef[]> => { + // 이미 캐시에 있으면 캐시된 값 사용 + if (subfieldCache[tagTypeCode]) { + return subfieldCache[tagTypeCode] + } + + try { + const { subFields } = await getSubfieldsByTagType(tagTypeCode) + + // API 응답을 SubFieldDef 형식으로 변환 + const formattedSubFields: SubFieldDef[] = subFields.map(field => ({ + name: field.name, + label: field.label, + type: field.type, + options: field.options || [], + expression: field.expression ?? undefined, + delimiter: field.delimiter ?? undefined, + })) + + // 캐시에 저장 + setSubfieldCache(prev => ({ + ...prev, + [tagTypeCode]: formattedSubFields + })) + + return formattedSubFields + } catch (error) { + console.error(`Error fetching subfields for tagType ${tagTypeCode}:`, error) + return [] + } + }, [subfieldCache]) + + // Class 기반 태그 번호 형식 검증 + const validateTagNumberByClass = React.useCallback(async ( + tagNo: string, + classLabel: string + ): Promise<string> => { + if (!tagNo) return "Tag number is empty." + if (!classLabel) return "Class is empty." + + try { + // 1. 클래스 라벨로 태그 타입 코드 찾기 + const tagTypeCode = getTagTypeCodeByClassLabel(classLabel) + if (!tagTypeCode) { + return `No tag type found for class '${classLabel}'.` + } + + // 2. 태그 타입 코드로 서브필드 가져오기 + const subfields = await fetchSubfieldsByTagType(tagTypeCode) + if (!subfields || subfields.length === 0) { + return `No subfields found for tag type code '${tagTypeCode}'.` + } + + // 3. 태그 번호를 파트별로 분석 + let remainingTagNo = tagNo + let currentPosition = 0 + + for (const field of subfields) { + // 구분자 확인 + const delimiter = field.delimiter || "" + + // 다음 구분자 위치 또는 문자열 끝 + let nextDelimiterPos + if (delimiter && remainingTagNo.includes(delimiter)) { + nextDelimiterPos = remainingTagNo.indexOf(delimiter) + } else { + nextDelimiterPos = remainingTagNo.length + } + + // 현재 파트 추출 + const part = remainingTagNo.substring(0, nextDelimiterPos) + + // 비어있으면 오류 + if (!part) { + return `Empty part for field '${field.label}'.` + } + + // 정규식 검증 + if (field.expression) { + const regex = new RegExp(`^${field.expression}$`) + if (!regex.test(part)) { + return `Part '${part}' for field '${field.label}' does not match the pattern '${field.expression}'.` + } + } + + // 선택 옵션 검증 + if (field.type === "select" && field.options && field.options.length > 0) { + const validValues = field.options.map(opt => opt.value) + if (!validValues.includes(part)) { + return `'${part}' is not a valid value for field '${field.label}'. Valid options: ${validValues.join(", ")}.` + } + } + + // 남은 문자열 업데이트 + if (delimiter && nextDelimiterPos < remainingTagNo.length) { + remainingTagNo = remainingTagNo.substring(nextDelimiterPos + delimiter.length) + } else { + remainingTagNo = "" + break + } + } + + // 문자열이 남아있으면 오류 + if (remainingTagNo) { + return `Tag number has extra parts: '${remainingTagNo}'.` + } + + return "" // 오류 없음 + } catch (error) { + console.error("Error validating tag number by class:", error) + return "Error validating tag number format." + } + }, [getTagTypeCodeByClassLabel, fetchSubfieldsByTagType]) + + // 기존 태그 번호 검증 함수 (기존 코드를 유지) + const validateTagNumber = React.useCallback(async (tagNo: string, tagType: string): Promise<string> => { + if (!tagNo) return "Tag number is empty." + if (!tagType) return "Tag type is empty." + + try { + // 1. 태그 타입에 대한 넘버링 룰 가져오기 + const rules = await fetchTagNumberingRules(tagType) + if (!rules || rules.length === 0) { + return `No numbering rules found for tag type '${tagType}'.` + } + + // 2. 정렬된 룰 (sortOrder 기준) + const sortedRules = [...rules].sort((a, b) => a.sortOrder - b.sortOrder) + + // 3. 태그 번호를 파트로 분리 + let remainingTagNo = tagNo + let currentPosition = 0 + + for (const rule of sortedRules) { + // 마지막 룰이 아니고 구분자가 있으면 + const delimiter = rule.delimiter || "" + + // 다음 구분자 위치 찾기 또는 문자열 끝 + let nextDelimiterPos + if (delimiter && remainingTagNo.includes(delimiter)) { + nextDelimiterPos = remainingTagNo.indexOf(delimiter) + } else { + nextDelimiterPos = remainingTagNo.length + } + + // 현재 파트 추출 + const part = remainingTagNo.substring(0, nextDelimiterPos) + + // 표현식이 있으면 검증 + if (rule.expression) { + const regex = new RegExp(`^${rule.expression}$`) + if (!regex.test(part)) { + return `Part '${part}' does not match the pattern '${rule.expression}' for ${rule.attributesDescription}.` + } + } + + // 옵션이 있는 경우 유효한 코드인지 확인 + const options = await fetchOptions(rule.attributesId) + if (options.length > 0) { + const isValidCode = options.some(opt => opt.code === part) + if (!isValidCode) { + return `'${part}' is not a valid code for ${rule.attributesDescription}. Valid options: ${options.map(o => o.code).join(', ')}.` + } + } + + // 남은 문자열 업데이트 + if (delimiter && nextDelimiterPos < remainingTagNo.length) { + remainingTagNo = remainingTagNo.substring(nextDelimiterPos + delimiter.length) + } else { + remainingTagNo = "" + break + } + + // 모든 룰을 처리했는데 문자열이 남아있으면 오류 + if (remainingTagNo && rule === sortedRules[sortedRules.length - 1]) { + return `Tag number has extra parts: '${remainingTagNo}'.` + } + } + + // 문자열이 남아있으면 오류 + if (remainingTagNo) { + return `Tag number has unprocessed parts: '${remainingTagNo}'.` + } + + return "" // 오류 없음 + } catch (error) { + console.error("Error validating tag number:", error) + return "Error validating tag number." + } + }, [fetchTagNumberingRules, fetchOptions]) + + /** + * 개선된 handleFileChange 함수 + * 1) ExcelJS로 파일 파싱 + * 2) 헤더 -> meta.excelHeader 매핑 + * 3) 각 행 유효성 검사 (Class 기반 검증 추가) + * 4) 에러 행 있으면 → 오류 메시지 기록 + 재다운로드 (상태 변경 안 함) + * 5) 정상 행만 importedRows 로 → 병합 + */ + async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) { + const file = e.target.files?.[0] + if (!file) return + + // 파일 input 초기화 + e.target.value = "" + setIsPending(true) + + try { + // 1) Workbook 로드 + const workbook = new ExcelJS.Workbook() + const arrayBuffer = await file.arrayBuffer() + await workbook.xlsx.load(arrayBuffer) + + // 첫 번째 시트 사용 + const worksheet = workbook.worksheets[0] + + // (A) 마지막 열에 "Error" 헤더 + const lastColIndex = worksheet.columnCount + 1 + worksheet.getRow(1).getCell(lastColIndex).value = "Error" + + // (B) 엑셀 헤더 (Row1) + const headerRowValues = worksheet.getRow(1).values as ExcelJS.CellValue[] + + // (C) excelHeader -> accessor 매핑 + const excelHeaderToAccessor: Record<string, string> = {} + for (const col of table.getAllColumns()) { + const meta = col.columnDef.meta as { excelHeader?: string } | undefined + if (meta?.excelHeader) { + const accessor = col.id as string + excelHeaderToAccessor[meta.excelHeader] = accessor + } + } + + // (D) accessor -> column index + const accessorIndexMap: Record<string, number> = {} + for (let i = 1; i < headerRowValues.length; i++) { + const cellVal = String(headerRowValues[i] ?? "").trim() + if (!cellVal) continue + const accessor = excelHeaderToAccessor[cellVal] + if (accessor) { + accessorIndexMap[accessor] = i + } + } + + let errorCount = 0 + const importedRows: Tag[] = [] + const fileTagNos = new Set<string>() // 파일 내 태그번호 중복 체크용 + const lastRow = worksheet.lastRow?.number || 1 + + // 2) 각 데이터 행 파싱 + for (let rowNum = 2; rowNum <= lastRow; rowNum++) { + const row = worksheet.getRow(rowNum) + const rowVals = row.values as ExcelJS.CellValue[] + if (!rowVals || rowVals.length <= 1) continue // 빈 행 스킵 + + let errorMsg = "" + + // 필요한 accessorIndex + const tagNoIndex = accessorIndexMap["tagNo"] + const classIndex = accessorIndexMap["class"] + + // 엑셀에서 값 읽기 + const tagNo = tagNoIndex ? String(rowVals[tagNoIndex] ?? "").trim() : "" + const classVal = classIndex ? String(rowVals[classIndex] ?? "").trim() : "" + + // A. 필수값 검사 + if (!tagNo) { + errorMsg += `Tag No is empty. ` + } + if (!classVal) { + errorMsg += `Class is empty. ` + } + + // B. 중복 검사 + if (tagNo) { + // 이미 tableData 내 존재 여부 + const dup = tableData.find( + (t) => t.contractItemId === selectedPackageId && t.tagNo === tagNo + ) + if (dup) { + errorMsg += `TagNo '${tagNo}' already exists. ` + } + + // 이번 엑셀 파일 내 중복 + if (fileTagNos.has(tagNo)) { + errorMsg += `TagNo '${tagNo}' is duplicated within this file. ` + } else { + fileTagNos.add(tagNo) + } + } + + // C. Class 기반 형식 검증 + if (tagNo && classVal && !errorMsg) { + // classVal 로부터 태그타입 코드 획득 + const tagTypeCode = getTagTypeCodeByClassLabel(classVal) + + if (!tagTypeCode) { + errorMsg += `No tag type code found for class '${classVal}'. ` + } else { + // validateTagNumberByClass( ) 안에서 + // → tagTypeCode로 서브필드 조회, 정규식 검증 등 처리 + const classValidationError = await validateTagNumberByClass(tagNo, classVal) + if (classValidationError) { + errorMsg += classValidationError + " " + } + } + } + + // D. 에러 처리 + if (errorMsg) { + row.getCell(lastColIndex).value = errorMsg.trim() + errorCount++ + } else { + // 최종 태그 타입 결정 (DB에 저장할 때 'tagType' 컬럼을 무엇으로 쓸지 결정) + // 예: DB에서 tagType을 "CV" 같은 코드로 저장하려면 + // const finalTagType = getTagTypeCodeByClassLabel(classVal) ?? "" + // 혹은 "Control Valve" 같은 description을 쓰려면 classOptions에서 찾아볼 수도 있음 + const finalTagType = getTagTypeCodeByClassLabel(classVal) ?? "" + + // 정상 행을 importedRows에 추가 + importedRows.push({ + id: 0, // 임시 + contractItemId: selectedPackageId, + formId: null, + tagNo, + tagType: finalTagType, // ← 코드로 저장할지, Description으로 저장할지 결정 + class: classVal, + description: String(rowVals[accessorIndexMap["description"] ?? 0] ?? "").trim(), + createdAt: new Date(), + updatedAt: new Date(), + }) + } + } + + // (E) 오류 행이 있으면 → 수정된 엑셀 재다운로드 & 종료 + if (errorCount > 0) { + const outBuf = await workbook.xlsx.writeBuffer() + const errorFile = new Blob([outBuf]) + const url = URL.createObjectURL(errorFile) + const link = document.createElement("a") + link.href = url + link.download = "tag_import_errors.xlsx" + link.click() + URL.revokeObjectURL(url) + + toast.error(`There are ${errorCount} error row(s). Please see downloaded file.`) + return + } + + // 정상 행이 있으면 태그 생성 요청 + if (importedRows.length > 0) { + const result = await bulkCreateTags(importedRows, selectedPackageId); + if ("error" in result) { + toast.error(result.error); + } else { + toast.success(`${result.data.createdCount}개의 태그가 성공적으로 생성되었습니다.`); + } + } + + toast.success(`Imported ${importedRows.length} tags successfully!`) + + } catch (err) { + console.error(err) + toast.error("파일 업로드 중 오류가 발생했습니다.") + } finally { + setIsPending(false) + } + } + // 새 Export 함수 - 유효성 검사 시트를 포함한 엑셀 내보내기 + async function handleExport() { + try { + setIsExporting(true) + + // 유효성 검사가 포함된 새로운 엑셀 내보내기 함수 호출 + await exportTagsToExcel(table, { + filename: `Tags_${selectedPackageId}`, + excludeColumns: ["select", "actions", "createdAt", "updatedAt"], + }) + + toast.success("태그 목록이 성공적으로 내보내졌습니다.") + } catch (error) { + console.error("Export error:", error) + toast.error("태그 목록 내보내기 중 오류가 발생했습니다.") + } finally { + setIsExporting(false) + } + } + + return ( + <div className="flex items-center gap-2"> + + {table.getFilteredSelectedRowModel().rows.length > 0 ? ( + <DeleteTagsDialog + tags={table + .getFilteredSelectedRowModel() + .rows.map((row) => row.original)} + onSuccess={() => table.toggleAllRowsSelected(false)} + selectedPackageId={selectedPackageId} + /> + ) : null} + + + <AddTagDialog selectedPackageId={selectedPackageId} /> + + {/* Import */} + <Button + variant="outline" + size="sm" + onClick={handleImportClick} + disabled={isPending || isExporting} + > + {isPending ? ( + <Loader2 className="size-4 mr-2 animate-spin" aria-hidden="true" /> + ) : ( + <Upload className="size-4 mr-2" aria-hidden="true" /> + )} + <span className="hidden sm:inline">Import</span> + </Button> + <input + ref={fileInputRef} + type="file" + accept=".xlsx,.xls" + className="hidden" + onChange={handleFileChange} + /> + + {/* Export */} + <Button + variant="outline" + size="sm" + onClick={handleExport} + disabled={isPending || isExporting} + > + {isExporting ? ( + <Loader2 className="size-4 mr-2 animate-spin" aria-hidden="true" /> + ) : ( + <Download className="size-4 mr-2" aria-hidden="true" /> + )} + <span className="hidden sm:inline">Export</span> + </Button> + </div> + ) +}
\ No newline at end of file diff --git a/lib/tags/table/update-tag-sheet.tsx b/lib/tags/table/update-tag-sheet.tsx new file mode 100644 index 00000000..27a1bdcb --- /dev/null +++ b/lib/tags/table/update-tag-sheet.tsx @@ -0,0 +1,548 @@ +"use client" + +import * as React from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { Loader2, Check, ChevronsUpDown } from "lucide-react" +import { useForm } from "react-hook-form" +import { toast } from "sonner" +import { z } from "zod" + +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command" +import { Badge } from "@/components/ui/badge" +import { cn } from "@/lib/utils" + +import { Tag } from "@/db/schema/vendorData" +import { updateTag, getSubfieldsByTagType, getClassOptions, TagTypeOption } from "@/lib/tags/service" + +// SubFieldDef 인터페이스 +interface SubFieldDef { + name: string + label: string + type: "select" | "text" + options?: { value: string; label: string }[] + expression?: string + delimiter?: string +} + +// 클래스 옵션 인터페이스 +interface UpdatedClassOption { + code: string + label: string + tagTypeCode: string + tagTypeDescription?: string +} + +// UpdateTagSchema 정의 +const updateTagSchema = z.object({ + class: z.string().min(1, "Class is required"), + tagType: z.string().min(1, "Tag Type is required"), + tagNo: z.string().min(1, "Tag Number is required"), + description: z.string().optional(), + // 추가 필드들은 동적으로 처리됨 +}) + +// TypeScript 타입 정의 +type UpdateTagSchema = z.infer<typeof updateTagSchema> & Record<string, string> + +interface UpdateTagSheetProps extends React.ComponentPropsWithRef<typeof Sheet> { + tag: Tag | null + selectedPackageId: number +} + +export function UpdateTagSheet({ tag, selectedPackageId, ...props }: UpdateTagSheetProps) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + const [tagTypeList, setTagTypeList] = React.useState<TagTypeOption[]>([]) + const [selectedTagTypeCode, setSelectedTagTypeCode] = React.useState<string | null>(null) + const [subFields, setSubFields] = React.useState<SubFieldDef[]>([]) + const [classOptions, setClassOptions] = React.useState<UpdatedClassOption[]>([]) + const [classSearchTerm, setClassSearchTerm] = React.useState("") + const [isLoadingClasses, setIsLoadingClasses] = React.useState(false) + const [isLoadingSubFields, setIsLoadingSubFields] = React.useState(false) + + // ID management for popover elements + const selectIdRef = React.useRef(0) + const fieldIdsRef = React.useRef<Record<string, string>>({}) + const classOptionIdsRef = React.useRef<Record<string, string>>({}) + + console.log(tag) + + // Load class options when sheet opens + React.useEffect(() => { + const loadClassOptions = async () => { + if (!props.open || !tag) return + + setIsLoadingClasses(true) + try { + const result = await getClassOptions() + setClassOptions(result) + } catch (err) { + toast.error("클래스 옵션을 불러오는데 실패했습니다.") + } finally { + setIsLoadingClasses(false) + } + } + + loadClassOptions() + }, [props.open, tag]) + + // Form setup + const form = useForm<UpdateTagSchema>({ + resolver: zodResolver(updateTagSchema), + defaultValues: { + class: "", + tagType: "", + tagNo: "", + description: "", + }, + }) + + // Load tag data into form when tag changes + React.useEffect(() => { + if (!tag) return + + // 필요한 필드만 선택적으로 추출 + const formValues = { + tagNo: tag.tagNo, + tagType: tag.tagType, + class: tag.class, + description: tag.description || "" + // 참고: 실제 태그 데이터에는 서브필드(functionCode, seqNumber 등)가 없음 + }; + + // 폼 초기화 + form.reset(formValues) + + // 태그 타입 코드 설정 (추가 필드 로딩을 위해) + if (tag.tagType) { + // 해당 태그 타입에 맞는 클래스 옵션을 찾아서 태그 타입 코드 설정 + const foundClass = classOptions.find(opt => opt.label === tag.class) + if (foundClass?.tagTypeCode) { + setSelectedTagTypeCode(foundClass.tagTypeCode) + loadSubFieldsByTagTypeCode(foundClass.tagTypeCode) + } + } + }, [tag, classOptions, form]) + + // Load subfields by tag type code + async function loadSubFieldsByTagTypeCode(tagTypeCode: string) { + setIsLoadingSubFields(true) + try { + const { subFields: apiSubFields } = await getSubfieldsByTagType(tagTypeCode) + const formattedSubFields: SubFieldDef[] = apiSubFields.map(field => ({ + name: field.name, + label: field.label, + type: field.type, + options: field.options || [], + expression: field.expression ?? undefined, + delimiter: field.delimiter ?? undefined, + })) + setSubFields(formattedSubFields) + return true + } catch (err) { + toast.error("서브필드를 불러오는데 실패했습니다.") + setSubFields([]) + return false + } finally { + setIsLoadingSubFields(false) + } + } + + // Handle class selection + async function handleSelectClass(classOption: UpdatedClassOption) { + form.setValue("class", classOption.label, { shouldValidate: true }) + + if (classOption.tagTypeCode) { + setSelectedTagTypeCode(classOption.tagTypeCode) + + // Set tag type + const tagType = tagTypeList.find(t => t.id === classOption.tagTypeCode) + if (tagType) { + form.setValue("tagType", tagType.label, { shouldValidate: true }) + } else if (classOption.tagTypeDescription) { + form.setValue("tagType", classOption.tagTypeDescription, { shouldValidate: true }) + } + + await loadSubFieldsByTagTypeCode(classOption.tagTypeCode) + } + } + + // Form submission handler + function onSubmit(data: UpdateTagSchema) { + startUpdateTransition(async () => { + if (!tag) return + + try { + // 기본 필드와 서브필드 데이터 결합 + const tagData = { + id: tag.id, + tagType: data.tagType, + class: data.class, + tagNo: data.tagNo, + description: data.description, + ...Object.fromEntries( + subFields.map(field => [field.name, data[field.name] || ""]) + ), + } + + const result = await updateTag(tagData, selectedPackageId) + + if ("error" in result) { + toast.error(result.error) + return + } + + form.reset() + props.onOpenChange?.(false) + toast.success("태그가 성공적으로 업데이트되었습니다") + } catch (error) { + console.error("Error updating tag:", error) + toast.error("태그 업데이트 중 오류가 발생했습니다") + } + }) + } + + // Render class field + function renderClassField(field: any) { + const [popoverOpen, setPopoverOpen] = React.useState(false) + + const buttonId = React.useMemo( + () => `class-button-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, + [] + ) + const popoverContentId = React.useMemo( + () => `class-popover-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, + [] + ) + const commandId = React.useMemo( + () => `class-command-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, + [] + ) + + return ( + <FormItem> + <FormLabel>Class</FormLabel> + <FormControl> + <Popover open={popoverOpen} onOpenChange={setPopoverOpen}> + <PopoverTrigger asChild> + <Button + key={buttonId} + type="button" + variant="outline" + className="w-full justify-between relative h-9" + disabled={isLoadingClasses} + > + {isLoadingClasses ? ( + <> + <span>클래스 로딩 중...</span> + <Loader2 className="ml-2 h-4 w-4 animate-spin" /> + </> + ) : ( + <> + <span className="truncate mr-1 flex-grow text-left"> + {field.value || "클래스 선택..."} + </span> + <ChevronsUpDown className="h-4 w-4 opacity-50 flex-shrink-0" /> + </> + )} + </Button> + </PopoverTrigger> + <PopoverContent key={popoverContentId} className="w-[300px] p-0"> + <Command key={commandId}> + <CommandInput + key={`${commandId}-input`} + placeholder="클래스 검색..." + value={classSearchTerm} + onValueChange={setClassSearchTerm} + /> + <CommandList key={`${commandId}-list`} className="max-h-[300px]"> + <CommandEmpty key={`${commandId}-empty`}>검색 결과가 없습니다.</CommandEmpty> + <CommandGroup key={`${commandId}-group`}> + {classOptions.map((opt, optIndex) => { + if (!classOptionIdsRef.current[opt.code]) { + classOptionIdsRef.current[opt.code] = + `class-${opt.code}-${Date.now()}-${Math.random() + .toString(36) + .slice(2, 9)}` + } + const optionId = classOptionIdsRef.current[opt.code] + + return ( + <CommandItem + key={`${optionId}-${optIndex}`} + onSelect={() => { + field.onChange(opt.label) + setPopoverOpen(false) + handleSelectClass(opt) + }} + value={opt.label} + className="truncate" + title={opt.label} + > + <span className="truncate">{opt.label}</span> + <Check + key={`${optionId}-check`} + className={cn( + "ml-auto h-4 w-4 flex-shrink-0", + field.value === opt.label ? "opacity-100" : "opacity-0" + )} + /> + </CommandItem> + ) + })} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + </FormControl> + <FormMessage /> + </FormItem> + ) + } + + // Render TagType field (readonly) + function renderTagTypeField(field: any) { + return ( + <FormItem> + <FormLabel>Tag Type</FormLabel> + <FormControl> + <div className="relative"> + <Input + {...field} + readOnly + className="h-9 bg-muted" + /> + </div> + </FormControl> + <FormMessage /> + </FormItem> + ) + } + + // Render Tag Number field (readonly) + function renderTagNoField(field: any) { + return ( + <FormItem> + <FormLabel>Tag Number</FormLabel> + <FormControl> + <div className="relative"> + <Input + {...field} + readOnly + className="h-9 bg-muted font-mono" + /> + </div> + </FormControl> + <FormMessage /> + </FormItem> + ) + } + + // Render form fields for each subfield + function renderSubFields() { + if (isLoadingSubFields) { + return ( + <div className="flex justify-center items-center py-4"> + <Loader2 className="h-6 w-6 animate-spin text-primary" /> + <div className="ml-3 text-muted-foreground">필드 로딩 중...</div> + </div> + ) + } + + if (subFields.length === 0) { + return null + } + + return ( + <div className="space-y-4"> + <div className="text-sm font-medium text-muted-foreground">추가 필드</div> + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + {subFields.map((sf, index) => ( + <FormField + key={`subfield-${sf.name}-${index}`} + control={form.control} + name={sf.name} + render={({ field }) => ( + <FormItem> + <FormLabel>{sf.label}</FormLabel> + <FormControl> + {sf.type === "select" ? ( + <Select + value={field.value || ""} + onValueChange={field.onChange} + > + <SelectTrigger className="w-full h-9"> + <SelectValue placeholder={`${sf.label} 선택...`} /> + </SelectTrigger> + <SelectContent + align="start" + side="bottom" + className="max-h-[250px]" + style={{ minWidth: "250px", maxWidth: "350px" }} + > + {sf.options?.map((opt, optIndex) => ( + <SelectItem + key={`${sf.name}-${opt.value}-${optIndex}`} + value={opt.value} + title={opt.label} + className="whitespace-normal py-2 break-words" + > + {opt.label} + </SelectItem> + ))} + </SelectContent> + </Select> + ) : ( + <Input + {...field} + className="h-9" + placeholder={`${sf.label} 입력...`} + /> + )} + </FormControl> + {sf.expression && ( + <p className="text-xs text-muted-foreground mt-1" title={sf.expression}> + {sf.expression} + </p> + )} + <FormMessage /> + </FormItem> + )} + /> + ))} + </div> + </div> + ) + } + + // 컴포넌트 렌더링 + return ( + <Sheet {...props}> + {/* <SheetContent className="flex flex-col gap-0 sm:max-w-md overflow-y-auto"> */} + <SheetContent className="flex flex-col gap-6 sm:max-w-lg overflow-y-auto"> + <SheetHeader className="text-left"> + <SheetTitle>태그 수정</SheetTitle> + <SheetDescription> + 태그 정보를 업데이트하고 변경 사항을 저장하세요 + </SheetDescription> + </SheetHeader> + + <div className="flex-1 overflow-y-auto py-4"> + <Form {...form}> + <form + id="update-tag-form" + onSubmit={form.handleSubmit(onSubmit)} + className="space-y-6" + > + {/* 기본 태그 정보 */} + <div className="space-y-4"> + {/* Class */} + <FormField + control={form.control} + name="class" + render={({ field }) => renderClassField(field)} + /> + + {/* Tag Type */} + <FormField + control={form.control} + name="tagType" + render={({ field }) => renderTagTypeField(field)} + /> + + {/* Tag Number */} + <FormField + control={form.control} + name="tagNo" + render={({ field }) => renderTagNoField(field)} + /> + + {/* Description */} + <FormField + control={form.control} + name="description" + render={({ field }) => ( + <FormItem> + <FormLabel>Description</FormLabel> + <FormControl> + <Input + {...field} + placeholder="태그 설명 입력..." + className="h-9" + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + + {/* 서브필드 */} + {renderSubFields()} + </form> + </Form> + </div> + + <SheetFooter className="pt-2"> + <SheetClose asChild> + <Button type="button" variant="outline"> + 취소 + </Button> + </SheetClose> + <Button + type="submit" + form="update-tag-form" + disabled={isUpdatePending || isLoadingSubFields} + > + {isUpdatePending ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" /> + 저장 중... + </> + ) : ( + "저장" + )} + </Button> + </SheetFooter> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/lib/tags/validations.ts b/lib/tags/validations.ts new file mode 100644 index 00000000..65e64f04 --- /dev/null +++ b/lib/tags/validations.ts @@ -0,0 +1,68 @@ +// /lib/tags/validations.ts +import { z } from "zod" +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, +} from "nuqs/server" +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" +import { Tag } from "@/db/schema/vendorData" + +export const createTagSchema = z.object({ + tagNo: z.string().min(1, "Tag No is required"), + tagType: z.string().min(1, "Tag Type is required"), + class: z.string().min(1, "Equipment Class is required"), + description: z.string().min(1, "Description is required"), // 필수 필드로 변경 + + // optional sub-fields for dynamic numbering + functionCode: z.string().optional(), + seqNumber: z.string().optional(), + valveAcronym: z.string().optional(), + processUnit: z.string().optional(), + + // If you also want contractItemId: + // contractItemId: z.number(), +}) + +export const updateTagSchema = z.object({ + id: z.number().optional(), // 업데이트 과정에서 별도 검증 + tagNo: z.string().min(1, "Tag Number is required"), + class: z.string().min(1, "Class is required"), + tagType: z.string().min(1, "Tag Type is required"), + description: z.string().optional(), + // 추가 필드들은 동적으로 추가될 수 있음 + functionCode: z.string().optional(), + seqNumber: z.string().optional(), + valveAcronym: z.string().optional(), + processUnit: z.string().optional(), + // 기타 필드들은 필요에 따라 추가 +}) + +export type UpdateTagSchema = z.infer<typeof updateTagSchema> + + +export const searchParamsCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault( + [] + ), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser<Tag>().withDefault([ + { id: "createdAt", desc: true }, + ]), + tagNo: parseAsString.withDefault(""), + tagType: parseAsString.withDefault(""), + description: parseAsString.withDefault(""), + + // advanced filter + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), + +}) + +export type CreateTagSchema = z.infer<typeof createTagSchema> +export type GetTagsSchema = Awaited<ReturnType<typeof searchParamsCache.parse>> + diff --git a/lib/tasks/repository.ts b/lib/tasks/repository.ts new file mode 100644 index 00000000..2e71ee20 --- /dev/null +++ b/lib/tasks/repository.ts @@ -0,0 +1,166 @@ +// src/lib/tasks/repository.ts +import db from "@/db/db"; +import { tasks, type Task } from "@/db/schema/tasks"; +import { + eq, + inArray, + not, + asc, + desc, + and, + ilike, + gte, + lte, + count, + gt, +} from "drizzle-orm"; +import { PgTransaction } from "drizzle-orm/pg-core"; +export type NewTask = typeof tasks.$inferInsert + +/** + * 단건/복수 조회 시 공통으로 사용 가능한 SELECT 함수 예시 + * - 트랜잭션(tx)을 받아서 사용하도록 구현 + */ +export async function selectTasks( + tx: PgTransaction<any, any, any>, + params: { + where?: any; // drizzle-orm의 조건식 (and, eq...) 등 + orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[]; + offset?: number; + limit?: number; + } + ) { + const { where, orderBy, offset = 0, limit = 10 } = params; + + return tx + .select() + .from(tasks) + .where(where) + .orderBy(...(orderBy ?? [])) + .offset(offset) + .limit(limit); + } +/** 총 개수 count */ +export async function countTasks( + tx: PgTransaction<any, any, any>, + where?: any +) { + const res = await tx.select({ count: count() }).from(tasks).where(where); + return res[0]?.count ?? 0; +} + +/** 단건 Insert 예시 */ +export async function insertTask( + tx: PgTransaction<any, any, any>, + data: NewTask // DB와 동일한 insert 가능한 타입 +) { + // returning() 사용 시 배열로 돌아오므로 [0]만 리턴 + return tx + .insert(tasks) + .values(data) + .returning({ id: tasks.id, createdAt: tasks.createdAt }); +} + +/** 복수 Insert 예시 */ +export async function insertTasks( + tx: PgTransaction<any, any, any>, + data: Task[] +) { + return tx.insert(tasks).values(data).onConflictDoNothing(); +} + +/** (방금 생성된 Task를 제외한) 가장 오래된 Task 하나 조회 */ +export async function selectOldestTaskExcept( + tx: PgTransaction<any, any, any>, + excludeId: string +) { + return tx + .select({ id: tasks.id, createdAt: tasks.createdAt }) + .from(tasks) + .where(not(eq(tasks.id, excludeId))) + .orderBy(asc(tasks.createdAt)) + .limit(1); +} + +/** 단건 삭제 */ +export async function deleteTaskById( + tx: PgTransaction<any, any, any>, + taskId: string +) { + return tx.delete(tasks).where(eq(tasks.id, taskId)); +} + +/** 복수 삭제 */ +export async function deleteTasksByIds( + tx: PgTransaction<any, any, any>, + ids: string[] +) { + return tx.delete(tasks).where(inArray(tasks.id, ids)); +} + +/** 전체 삭제 */ +export async function deleteAllTasks( + tx: PgTransaction<any, any, any>, +) { + return tx.delete(tasks); +} + +/** 단건 업데이트 */ +export async function updateTask( + tx: PgTransaction<any, any, any>, + taskId: string, + data: Partial<Task> +) { + return tx + .update(tasks) + .set(data) + .where(eq(tasks.id, taskId)) + .returning({ status: tasks.status, priority: tasks.priority }); +} + +/** 복수 업데이트 */ +export async function updateTasks( + tx: PgTransaction<any, any, any>, + ids: string[], + data: Partial<Task> +) { + return tx + .update(tasks) + .set(data) + .where(inArray(tasks.id, ids)) + .returning({ status: tasks.status, priority: tasks.priority }); +} + +/** status 기준 groupBy */ +export async function groupByStatus( + tx: PgTransaction<any, any, any>, +) { + return tx + .select({ + status: tasks.status, + count: count(), + }) + .from(tasks) + .groupBy(tasks.status) + .having(gt(count(), 0)); +} + +/** priority 기준 groupBy */ +export async function groupByPriority( + tx: PgTransaction<any, any, any>, +) { + return tx + .select({ + priority: tasks.priority, + count: count(), + }) + .from(tasks) + .groupBy(tasks.priority) + .having(gt(count(), 0)); +} + +// 모든 task 조회 +export const getAllTasks = async (): Promise<Task[]> => { + const users = await db.select().from(tasks).execute(); + return users +}; diff --git a/lib/tasks/service.ts b/lib/tasks/service.ts new file mode 100644 index 00000000..c31ecd4b --- /dev/null +++ b/lib/tasks/service.ts @@ -0,0 +1,561 @@ +// src/lib/tasks/service.ts +"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택) + +import { revalidateTag, unstable_noStore } from "next/cache"; +import db from "@/db/db"; +import { tasks, type Task } from "@/db/schema/tasks"; +import { customAlphabet } from "nanoid"; + +import { filterColumns } from "@/lib/filter-columns"; +import { unstable_cache } from "@/lib/unstable-cache"; +import { getErrorMessage } from "@/lib/handle-error"; + +import type { CreateTaskSchema, UpdateTaskSchema, GetTasksSchema } from "./validations"; +import { asc, desc, ilike, inArray, and, gte, lte, not, or } from "drizzle-orm"; + +// 레포지토리 함수들 +import { + selectTasks, + countTasks, + insertTask, + insertTasks, + selectOldestTaskExcept, + deleteTaskById, + deleteTasksByIds, + deleteAllTasks, + updateTask, + updateTasks, + groupByStatus, + groupByPriority, + getAllTasks, +} from "./repository"; + +import ExcelJS from "exceljs" +import { tasksColumnsConfig, type TaskColumnConfig } from "@/config/tasksColumnsConfig" + +interface ImportResult { + errorFile: File | null + errorMessage: string | null + successMessage?: string +} +/* ----------------------------------------------------- + 1) 조회 관련 +----------------------------------------------------- */ + +/** + * 복잡한 조건으로 Task 목록을 조회 (+ pagination) 하고, + * 총 개수에 따라 pageCount를 계산해서 리턴. + * Next.js의 unstable_cache를 사용해 일정 시간 캐시. + */ +export async function getTasks(input: GetTasksSchema) { + + return unstable_cache( + async () => { + try { + const offset = (input.page - 1) * input.perPage; + const fromDate = input.from ? new Date(input.from) : undefined; + const toDate = input.to ? new Date(input.to) : undefined; + // const advancedTable = input.flags.includes("advancedTable"); + const advancedTable = true; + + // advancedTable 모드면 filterColumns()로 where 절 구성 + const advancedWhere = filterColumns({ + table: tasks, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + + let globalWhere + if (input.search) { + const s = `%${input.search}%` + globalWhere = or(ilike(tasks.title, s), ilike(tasks.code, s) + , ilike(tasks.status, s) + ) + // 필요시 여러 칼럼 OR조건 (status, priority, etc) + } + + const finalWhere = and( + // advancedWhere or your existing conditions + advancedWhere, + globalWhere // and()함수로 결합 or or() 등으로 결합 + ) + + + // 아니면 ilike, inArray, gte 등으로 where 절 구성 + const where = advancedTable + ? finalWhere + : and( + input.title ? ilike(tasks.title, `%${input.title}%`) : undefined, + input.status.length > 0 ? inArray(tasks.status, input.status) : undefined, + input.priority.length > 0 ? inArray(tasks.priority, input.priority) : undefined, + fromDate ? gte(tasks.createdAt, fromDate) : undefined, + toDate ? lte(tasks.createdAt, toDate) : undefined + ); + + const orderBy = + input.sort.length > 0 + ? input.sort.map((item) => + item.desc ? desc(tasks[item.id]) : asc(tasks[item.id]) + ) + : [asc(tasks.createdAt)]; + + // 트랜잭션 내부에서 Repository 호출 + const { data, total } = await db.transaction(async (tx) => { + const data = await selectTasks(tx, { + where, + orderBy, + offset, + limit: input.perPage, + }); + const total = await countTasks(tx, where); + return { data, total }; + }); + + const pageCount = Math.ceil(total / input.perPage); + +// console.log("===> advancedWhere:", advancedWhere); +// console.log("===> globalWhere:", globalWhere); +// console.log("===> finalWhere:", finalWhere); +// console.log("===> offset:", offset, " limit:", input.perPage); + + return { data, pageCount }; + } catch (err) { + // 에러 발생 시 디폴트 + return { data: [], pageCount: 0 }; + } + }, + [JSON.stringify(input)], // 캐싱 키 + { + revalidate: 3600, + tags: ["tasks"], // revalidateTag("tasks") 호출 시 무효화 + } + )(); +} + + +/** Status별 개수 */ +export async function getTaskStatusCounts() { + return unstable_cache( + async () => { + try { + + const initial: Record<Task["status"], number> = { + todo: 0, + "in-progress": 0, + done: 0, + canceled: 0, + }; + + + const result = await db.transaction(async (tx) => { + const rows = await groupByStatus(tx); + return rows.reduce<Record<Task["status"], number>>((acc, { status, count }) => { + acc[status] = count; + return acc; + }, initial); + }); + + return result; + } catch (err) { + return {} as Record<Task["status"], number>; + } + }, + ["task-status-counts"], // 캐싱 키 + { + revalidate: 3600, + } + )(); +} + +/** Priority별 개수 */ +export async function getTaskPriorityCounts() { + return unstable_cache( + async () => { + try { + + const initial: Record<Task["priority"], number> = { + low: 0, + medium: 0, + high: 0, + }; + + const result = await db.transaction(async (tx) => { + const rows = await groupByPriority(tx); + return rows.reduce<Record<Task["priority"], number>>((acc, { priority, count }) => { + acc[priority] = count; + return acc; + }, initial); + }); + + return result; + } catch (err) { + return {} as Record<Task["priority"], number>; + } + }, + ["task-priority-counts"], + { + revalidate: 3600, + } + )(); +} + +/* ----------------------------------------------------- + 2) 생성(Create) +----------------------------------------------------- */ + + +/** + * Task 생성 후, (가장 오래된 Task 1개) 삭제로 + * 전체 Task 개수를 고정 + */ +export async function createTask(input: CreateTaskSchema) { + unstable_noStore(); // Next.js 서버 액션 캐싱 방지 + try { + await db.transaction(async (tx) => { + // 새 Task 생성 + const [newTask] = await insertTask(tx, { + title: input.title, + status: input.status, + label: input.label, + priority: input.priority, + }); + return newTask; + + }); + + console.log("tasks") + + // 캐시 무효화 + revalidateTag("tasks"); + revalidateTag("task-status-counts"); + revalidateTag("task-priority-counts"); + + return { data: null, error: null }; + } catch (err) { + return { data: null, error: getErrorMessage(err) }; + } +} + +/* ----------------------------------------------------- + 3) 업데이트 +----------------------------------------------------- */ + +/** 단건 업데이트 */ +export async function modifiTask(input: UpdateTaskSchema & { id: string }) { + unstable_noStore(); + try { + const data = await db.transaction(async (tx) => { + const [res] = await updateTask(tx, input.id, { + title: input.title, + label: input.label, + status: input.status, + priority: input.priority, + }); + return res; + }); + + revalidateTag("tasks"); + if (data.status === input.status) { + revalidateTag("task-status-counts"); + } + if (data.priority === input.priority) { + revalidateTag("task-priority-counts"); + } + + return { data: null, error: null }; + } catch (err) { + return { data: null, error: getErrorMessage(err) }; + } +} + +/** 복수 업데이트 */ +export async function modifiTasks(input: { + ids: string[]; + label?: Task["label"]; + status?: Task["status"]; + priority?: Task["priority"]; +}) { + unstable_noStore(); + try { + const data = await db.transaction(async (tx) => { + const [res] = await updateTasks(tx, input.ids, { + label: input.label, + status: input.status, + priority: input.priority, + }); + return res; + }); + + revalidateTag("tasks"); + if (data.status === input.status) { + revalidateTag("task-status-counts"); + } + if (data.priority === input.priority) { + revalidateTag("task-priority-counts"); + } + + return { data: null, error: null }; + } catch (err) { + return { data: null, error: getErrorMessage(err) }; + } +} + +/* ----------------------------------------------------- + 4) 삭제 +----------------------------------------------------- */ + +/** 단건 삭제 */ +export async function removeTask(input: { id: string }) { + unstable_noStore(); + try { + await db.transaction(async (tx) => { + // 삭제 + await deleteTaskById(tx, input.id); + // 바로 새 Task 생성 + }); + + revalidateTag("tasks"); + revalidateTag("task-status-counts"); + revalidateTag("task-priority-counts"); + + return { data: null, error: null }; + } catch (err) { + return { data: null, error: getErrorMessage(err) }; + } +} + +/** 복수 삭제 */ +export async function removeTasks(input: { ids: string[] }) { + unstable_noStore(); + try { + await db.transaction(async (tx) => { + // 삭제 + await deleteTasksByIds(tx, input.ids); + }); + + revalidateTag("tasks"); + revalidateTag("task-status-counts"); + revalidateTag("task-priority-counts"); + + return { data: null, error: null }; + } catch (err) { + return { data: null, error: getErrorMessage(err) }; + } +} + +/** + * 1) 그룹 헤더(2줄 헤더)인지, 1줄 헤더인지 판별 + * - Row1이 `group` 값들로 이루어져 있고, + * - Row2가 `excelHeader` 값들과 매칭되는지 + */ +function detectHasGroupHeader( + worksheet: ExcelJS.Worksheet, + config: TaskColumnConfig[] +): boolean { + // 전체 group 목록 + const groupSet = new Set( + config.filter((c) => c.group).map((c) => c.group!.trim()) + ) + // 전체 excelHeader 목록 + const headerSet = new Set( + config.filter((c) => c.excelHeader).map((c) => c.excelHeader!.trim()) + ) + + + // row1이 전부(또는 대부분) groupSet에 속하면 => 그룹 헤더일 가능성 높음 + // row1Values = (index 0은 비어있을 수 있으므로) 안전하게 string 변환 후 trim + const row1Values = (worksheet.getRow(1)?.values ?? []) as (string | null | undefined)[] + const row2Values = (worksheet.getRow(2)?.values ?? []) as (string | null | undefined)[] + + // row1Values가 전부 groupSet 내에 있거나 빈 문자열이면, "이건 그룹 헤더" + const row1IsMostlyGroup = row1Values.every((val) => { + if (!val) { + return true + } + return groupSet.has(val.trim()) + }) + // row2 중에 headerSet에 포함되는 값이 몇 개나 되는가? + // 즉, row2가 실제로 excelHeader로 구성되어 있으면 -> 2줄 헤더 가능성 + const row2HeaderCount = row2Values.filter((val) => { + // val이 string인지 확인 + if (typeof val === "string") { + return headerSet.has(val.trim()) + } + // null/undefined(또는 숫자, 객체 등)이면 필터링 제외 + return false + }).length + + // (단순 로직) row1이 그룹 같고, row2가 적어도 1개 이상 excelHeader 매칭 => 2줄 헤더 + // 프로젝트에 맞춰 좀 더 세밀하게 조건을 잡아도 됨. + if (row1IsMostlyGroup && row2HeaderCount > 0) { + return true + } + return false +} + +export async function importTasksExcel(file: File): Promise<ImportResult> { + try { + // 1) 엑셀 로드 + const buffer = await file.arrayBuffer() + const workbook = new ExcelJS.Workbook() + await workbook.xlsx.load(buffer) + + // 첫 번째 시트만 사용 + const worksheet = workbook.worksheets[0] + if (!worksheet) { + throw new Error("엑셀 파일에 시트가 없습니다.") + } + + // 2) 그룹 헤더(2줄) or 일반 헤더(1줄) 판별 + const hasGroupHeader = detectHasGroupHeader(worksheet, tasksColumnsConfig) + const headerRowIndex = hasGroupHeader ? 2 : 1 + const dataStartRowIndex = hasGroupHeader ? 3 : 2 + + const headerRow = worksheet.getRow(headerRowIndex) + if (!headerRow) { + throw new Error("엑셀 헤더 행을 찾지 못했습니다.") + } + + // 3) 엑셀 헤더(문자열) → 컬럼 인덱스(Map) + const columnIndexMap = new Map<string, number>() + headerRow.eachCell((cell, colIndex) => { + if (typeof cell.value === "string") { + columnIndexMap.set(cell.value.trim(), colIndex) + } + }) + + // 4) columnToFieldMap: "엑셀 열 인덱스" → "DB 필드(Task의 keyof)" + // 예) "Code" → "code", "Title" → "title", ... + const columnToFieldMap = new Map<number, keyof Task>() + tasksColumnsConfig.forEach((cfg) => { + if (!cfg.excelHeader) return + const colIndex = columnIndexMap.get(cfg.excelHeader.trim()) + if (colIndex !== undefined) { + // 예: colIndex=1 -> cfg.id="code" + columnToFieldMap.set(colIndex, cfg.id) + } + }) + + // 5) 에러가 발생하면 표시할 용도 + const errorRows: { rowIndex: number; message: string }[] = [] + + // 6) 엑셀에서 읽어온 행 데이터를 임시 보관 + // "마지막 컬럼(D/d) → toDelete=true" + type ExcelRowData = { + rowIndex: number + fields: Partial<Task> + toDelete: boolean + } + const rowDataList: ExcelRowData[] = [] + + for (let r = dataStartRowIndex; r <= worksheet.rowCount; r++) { + const row = worksheet.getRow(r) + if (!row ) continue + + // (6-1) 마지막 셀을 보고 DELETE 여부 판단 + const lastCellValue = row.getCell(row.cellCount).value + const isDelete = + typeof lastCellValue === "string" && + lastCellValue.toLowerCase() === "d" + + // (6-2) 각 열 -> DB 필드 매핑 + const fields = {} as Partial<Task> + + columnToFieldMap.forEach((fieldId, colIdx) => { + const cellValue = row.getCell(colIdx).value + if (fieldId === "createdAt") { + fields.createdAt = undefined + } else { + fields[fieldId] = (cellValue ?? null) as any + } + }) + + rowDataList.push({ + rowIndex: r, + fields, + toDelete: isDelete, + }) + } + + // (6-3) 혹시 이 시점에서 "필수 값이 누락됐다" 등의 검증을 하고 싶다면 errorRows.push(...) + // if (errorRows.length > 0) => 엑셀에 표시 후 리턴 (생략) + + // 7) 현재 DB에 있는 "code" 목록을 가져온다 + const existingCodes = await getAllTasks().then((rows) => rows.map((r) => r.code)) + const existingCodeSet = new Set<string>(existingCodes.filter(Boolean)) + + // 8) CREATE/UPDATE/DELETE 목록 분리 + const toCreate: Task[] = [] + // (updateTasks 함수가 "ids: string[], data: Partial<Task>" 형태) + // - 여러 code를 한꺼번에 업데이트할 수도 있지만, 여기선 간단히 1code씩 + const toUpdate: { codes: string[]; data: Partial<Task> }[] = [] + const toDeleteCodes: string[] = [] + + for (const { rowIndex, fields, toDelete } of rowDataList) { + // code를 string으로 캐스팅 + const code = fields.code ? String(fields.code).trim() : "" + + if (toDelete) { + // DELETE + if (code && existingCodeSet.has(code)) { + toDeleteCodes.push(code) + } + // code가 없거나 DB에 없으면 무시 + continue + } + + // CREATE or UPDATE + if (!code) { + + toCreate.push(fields as Task) + } else { + // code가 있고, DB에도 있으면 UPDATE + if (existingCodeSet.has(code)) { + toUpdate.push({ codes: [code], data: fields }) + } else { + // code가 있지만 DB에 없으면 CREATE + toCreate.push(fields as Task) + } + } + } + + // (선택) 에러가 있으면 여기서 다시 한 번 errorRows에 추가 후 반환 가능 + + // 9) 트랜잭션으로 처리 + await db.transaction(async (tx) => { + // CREATE + if (toCreate.length > 0) { + await insertTasks(tx, toCreate) + } + // UPDATE + if (toUpdate.length > 0) { + for (const { codes, data } of toUpdate) { + await updateTasks(tx, codes, data) + } + } + // DELETE + if (toDeleteCodes.length > 0) { + await deleteTasksByIds(tx, toDeleteCodes) + } + }) + + // 10) 성공 메시지 + const msg: string[] = [] + if (toCreate.length > 0) msg.push(`${toCreate.length}건 생성`) + if (toUpdate.length > 0) msg.push(`${toUpdate.length}건 수정`) + if (toDeleteCodes.length > 0) msg.push(`${toDeleteCodes.length}건 삭제`) + const successMessage = msg.length > 0 ? msg.join(", ") : "No changes" + + return { + errorFile: null, + errorMessage: null, + successMessage, + } + } catch (err: any) { + return { + errorFile: null, + errorMessage: err.message || "Import 중 오류가 발생했습니다.", + } + } +}
\ No newline at end of file diff --git a/lib/tasks/table/add-tasks-dialog.tsx b/lib/tasks/table/add-tasks-dialog.tsx new file mode 100644 index 00000000..18a9a4b2 --- /dev/null +++ b/lib/tasks/table/add-tasks-dialog.tsx @@ -0,0 +1,227 @@ +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" + +import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +// react-hook-form + shadcn/ui Form +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +// shadcn/ui Select +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" + +import { tasks } from "@/db/schema/tasks" // enumValues 가져올 DB 스키마 +import { createTaskSchema, type CreateTaskSchema } from "@/lib/tasks/validations" +import { createTask } from "@/lib/tasks/service" // 서버 액션 혹은 API + +export function AddTaskDialog() { + const [open, setOpen] = React.useState(false) + + // react-hook-form 세팅 + const form = useForm<CreateTaskSchema>({ + resolver: zodResolver(createTaskSchema), + defaultValues: { + title: "", + label: tasks.label.enumValues[0] ?? "", // enumValues 중 첫 번째를 기본값으로 + status: tasks.status.enumValues[0] ?? "", + priority: tasks.priority.enumValues[0] ?? "", + }, + }) + + async function onSubmit(data: CreateTaskSchema) { + const result = await createTask(data) + if (result.error) { + alert(`에러: ${result.error}`) + return + } + // 성공 시 모달 닫고 폼 리셋 + form.reset() + setOpen(false) + } + + function handleDialogOpenChange(nextOpen: boolean) { + if (!nextOpen) { + form.reset() + } + setOpen(nextOpen) + } + + return ( + <Dialog open={open} onOpenChange={handleDialogOpenChange}> + {/* 모달을 열기 위한 버튼 */} + <DialogTrigger asChild> + <Button variant="default" size="sm"> + Add Task + </Button> + </DialogTrigger> + + <DialogContent> + <DialogHeader> + <DialogTitle>Create New Task</DialogTitle> + <DialogDescription> + 새 Task 정보를 입력하고 <b>Create</b> 버튼을 누르세요. + </DialogDescription> + </DialogHeader> + + {/* shadcn/ui Form을 이용해 react-hook-form과 연결 */} + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)}> + <div className="space-y-4 py-4"> + {/* Title 필드 */} + <FormField + control={form.control} + name="title" + render={({ field }) => ( + <FormItem> + <FormLabel>Title</FormLabel> + <FormControl> + <Input + placeholder="e.g. Fix the layout bug" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Label (Select) */} + <FormField + control={form.control} + name="label" + render={({ field }) => ( + <FormItem> + <FormLabel>Label</FormLabel> + <FormControl> + <Select + onValueChange={field.onChange} + value={field.value} + > + <SelectTrigger className="capitalize"> + <SelectValue placeholder="Select a label" /> + </SelectTrigger> + <SelectContent> + <SelectGroup> + {tasks.label.enumValues.map((item) => ( + <SelectItem + key={item} + value={item} + className="capitalize" + > + {item} + </SelectItem> + ))} + </SelectGroup> + </SelectContent> + </Select> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Status (Select) */} + <FormField + control={form.control} + name="status" + render={({ field }) => ( + <FormItem> + <FormLabel>Status</FormLabel> + <FormControl> + <Select + onValueChange={field.onChange} + value={field.value} + > + <SelectTrigger className="capitalize"> + <SelectValue placeholder="Select a status" /> + </SelectTrigger> + <SelectContent> + <SelectGroup> + {tasks.status.enumValues.map((item) => ( + <SelectItem + key={item} + value={item} + className="capitalize" + > + {item} + </SelectItem> + ))} + </SelectGroup> + </SelectContent> + </Select> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Priority (Select) */} + <FormField + control={form.control} + name="priority" + render={({ field }) => ( + <FormItem> + <FormLabel>Priority</FormLabel> + <FormControl> + <Select + onValueChange={field.onChange} + value={field.value} + > + <SelectTrigger className="capitalize"> + <SelectValue placeholder="Select a priority" /> + </SelectTrigger> + <SelectContent> + <SelectGroup> + {tasks.priority.enumValues.map((item) => ( + <SelectItem + key={item} + value={item} + className="capitalize" + > + {item} + </SelectItem> + ))} + </SelectGroup> + </SelectContent> + </Select> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => setOpen(false)} + > + Cancel + </Button> + <Button type="submit" disabled={form.formState.isSubmitting}> + Create + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/tasks/table/delete-tasks-dialog.tsx b/lib/tasks/table/delete-tasks-dialog.tsx new file mode 100644 index 00000000..c82c913e --- /dev/null +++ b/lib/tasks/table/delete-tasks-dialog.tsx @@ -0,0 +1,149 @@ +"use client" + +import * as React from "react" +import { type Task } from "@/db/schema/tasks" +import { type Row } from "@tanstack/react-table" +import { Loader, Trash } from "lucide-react" +import { toast } from "sonner" + +import { useMediaQuery } from "@/hooks/use-media-query" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer" + +import { removeTasks } from "@/lib//tasks/service" + +interface DeleteTasksDialogProps + extends React.ComponentPropsWithoutRef<typeof Dialog> { + tasks: Row<Task>["original"][] + showTrigger?: boolean + onSuccess?: () => void +} + +export function DeleteTasksDialog({ + tasks, + showTrigger = true, + onSuccess, + ...props +}: DeleteTasksDialogProps) { + const [isDeletePending, startDeleteTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + + function onDelete() { + startDeleteTransition(async () => { + const { error } = await removeTasks({ + ids: tasks.map((task) => task.id), + }) + + if (error) { + toast.error(error) + return + } + + props.onOpenChange?.(false) + toast.success("Tasks deleted") + onSuccess?.() + }) + } + + if (isDesktop) { + return ( + <Dialog {...props}> + {showTrigger ? ( + <DialogTrigger asChild> + <Button variant="outline" size="sm"> + <Trash className="mr-2 size-4" aria-hidden="true" /> + Delete ({tasks.length}) + </Button> + </DialogTrigger> + ) : null} + <DialogContent> + <DialogHeader> + <DialogTitle>Are you absolutely sure?</DialogTitle> + <DialogDescription> + This action cannot be undone. This will permanently delete your{" "} + <span className="font-medium">{tasks.length}</span> + {tasks.length === 1 ? " task" : " tasks"} from our servers. + </DialogDescription> + </DialogHeader> + <DialogFooter className="gap-2 sm:space-x-0"> + <DialogClose asChild> + <Button variant="outline">Cancel</Button> + </DialogClose> + <Button + aria-label="Delete selected rows" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending} + > + {isDeletePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + Delete + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) + } + + return ( + <Drawer {...props}> + {showTrigger ? ( + <DrawerTrigger asChild> + <Button variant="outline" size="sm"> + <Trash className="mr-2 size-4" aria-hidden="true" /> + Delete ({tasks.length}) + </Button> + </DrawerTrigger> + ) : null} + <DrawerContent> + <DrawerHeader> + <DrawerTitle>Are you absolutely sure?</DrawerTitle> + <DrawerDescription> + This action cannot be undone. This will permanently delete your{" "} + <span className="font-medium">{tasks.length}</span> + {tasks.length === 1 ? " task" : " tasks"} from our servers. + </DrawerDescription> + </DrawerHeader> + <DrawerFooter className="gap-2 sm:space-x-0"> + <DrawerClose asChild> + <Button variant="outline">Cancel</Button> + </DrawerClose> + <Button + aria-label="Delete selected rows" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending} + > + {isDeletePending && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + Delete + </Button> + </DrawerFooter> + </DrawerContent> + </Drawer> + ) +} diff --git a/lib/tasks/table/feature-flags-provider.tsx b/lib/tasks/table/feature-flags-provider.tsx new file mode 100644 index 00000000..81131894 --- /dev/null +++ b/lib/tasks/table/feature-flags-provider.tsx @@ -0,0 +1,108 @@ +"use client" + +import * as React from "react" +import { useQueryState } from "nuqs" + +import { dataTableConfig, type DataTableConfig } from "@/config/data-table" +import { cn } from "@/lib/utils" +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" + +type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"] + +interface FeatureFlagsContextProps { + featureFlags: FeatureFlagValue[] + setFeatureFlags: (value: FeatureFlagValue[]) => void +} + +const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({ + featureFlags: [], + setFeatureFlags: () => {}, +}) + +export function useFeatureFlags() { + const context = React.useContext(FeatureFlagsContext) + if (!context) { + throw new Error( + "useFeatureFlags must be used within a FeatureFlagsProvider" + ) + } + return context +} + +interface FeatureFlagsProviderProps { + children: React.ReactNode +} + +export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) { + const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>( + "flags", + { + defaultValue: [], + parse: (value) => value.split(",") as FeatureFlagValue[], + serialize: (value) => value.join(","), + eq: (a, b) => + a.length === b.length && a.every((value, index) => value === b[index]), + clearOnDefault: true, + shallow: false, + } + ) + + return ( + <FeatureFlagsContext.Provider + value={{ + featureFlags, + setFeatureFlags: (value) => void setFeatureFlags(value), + }} + > + <div className="w-full overflow-x-auto"> + <ToggleGroup + type="multiple" + variant="outline" + size="sm" + value={featureFlags} + onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)} + className="w-fit gap-0" + > + {dataTableConfig.featureFlags.map((flag, index) => ( + <Tooltip key={flag.value}> + <ToggleGroupItem + value={flag.value} + className={cn( + "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90", + { + "rounded-l-sm border-r-0": index === 0, + "rounded-r-sm": + index === dataTableConfig.featureFlags.length - 1, + } + )} + asChild + > + <TooltipTrigger> + <flag.icon className="size-3.5 shrink-0" aria-hidden="true" /> + {flag.label} + </TooltipTrigger> + </ToggleGroupItem> + <TooltipContent + align="start" + side="bottom" + sideOffset={6} + className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground" + > + <div>{flag.tooltipTitle}</div> + <div className="text-xs text-muted-foreground"> + {flag.tooltipDescription} + </div> + </TooltipContent> + </Tooltip> + ))} + </ToggleGroup> + </div> + {children} + </FeatureFlagsContext.Provider> + ) +} diff --git a/lib/tasks/table/feature-flags.tsx b/lib/tasks/table/feature-flags.tsx new file mode 100644 index 00000000..aaae6af2 --- /dev/null +++ b/lib/tasks/table/feature-flags.tsx @@ -0,0 +1,96 @@ +"use client" + +import * as React from "react" +import { useQueryState } from "nuqs" + +import { dataTableConfig, type DataTableConfig } from "@/config/data-table" +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" + +type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"] + +interface TasksTableContextProps { + featureFlags: FeatureFlagValue[] + setFeatureFlags: (value: FeatureFlagValue[]) => void +} + +const TasksTableContext = React.createContext<TasksTableContextProps>({ + featureFlags: [], + setFeatureFlags: () => {}, +}) + +export function useTasksTable() { + const context = React.useContext(TasksTableContext) + if (!context) { + throw new Error("useTasksTable must be used within a TasksTableProvider") + } + return context +} + +export function TasksTableProvider({ children }: React.PropsWithChildren) { + const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>( + "featureFlags", + { + defaultValue: [], + parse: (value) => value.split(",") as FeatureFlagValue[], + serialize: (value) => value.join(","), + eq: (a, b) => + a.length === b.length && a.every((value, index) => value === b[index]), + clearOnDefault: true, + } + ) + + return ( + <TasksTableContext.Provider + value={{ + featureFlags, + setFeatureFlags: (value) => void setFeatureFlags(value), + }} + > + <div className="w-full overflow-x-auto"> + <ToggleGroup + type="multiple" + variant="outline" + size="sm" + value={featureFlags} + onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)} + className="w-fit" + > + {dataTableConfig.featureFlags.map((flag) => ( + <Tooltip key={flag.value}> + <ToggleGroupItem + value={flag.value} + className="whitespace-nowrap px-3 text-xs" + asChild + > + <TooltipTrigger> + <flag.icon + className="mr-2 size-3.5 shrink-0" + aria-hidden="true" + /> + {flag.label} + </TooltipTrigger> + </ToggleGroupItem> + <TooltipContent + align="start" + side="bottom" + sideOffset={6} + className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground" + > + <div>{flag.tooltipTitle}</div> + <div className="text-xs text-muted-foreground"> + {flag.tooltipDescription} + </div> + </TooltipContent> + </Tooltip> + ))} + </ToggleGroup> + </div> + {children} + </TasksTableContext.Provider> + ) +} diff --git a/lib/tasks/table/tasks-table-columns.tsx b/lib/tasks/table/tasks-table-columns.tsx new file mode 100644 index 00000000..3737c2e5 --- /dev/null +++ b/lib/tasks/table/tasks-table-columns.tsx @@ -0,0 +1,262 @@ +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { Ellipsis } from "lucide-react" +import { toast } from "sonner" + +import { getErrorMessage } from "@/lib/handle-error" +import { formatDate } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header" + +import { modifiTask } from "@/lib/tasks/service" +import { getPriorityIcon, getStatusIcon } from "@/lib/tasks/utils" +import { tasks } from "@/db/schema/tasks" +import type { Task } from "@/db/schema/tasks" + +import { tasksColumnsConfig } from "@/config/tasksColumnsConfig" + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<Task> | null>> +} + +/** + * tanstack table 컬럼 정의 (중첩 헤더 버전) + */ +export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<Task>[] { + // ---------------------------------------------------------------- + // 1) select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef<Task> = { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + size:40, + enableSorting: false, + enableHiding: false, + } + + // ---------------------------------------------------------------- + // 2) actions 컬럼 (Dropdown 메뉴) + // ---------------------------------------------------------------- + const actionsColumn: ColumnDef<Task> = { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-8 p-0 data-[state=open]:bg-muted" + > + <Ellipsis className="size-4" aria-hidden="true" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-40"> + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "update" })} + > + Edit + </DropdownMenuItem> + <DropdownMenuSub> + <DropdownMenuSubTrigger>Labels</DropdownMenuSubTrigger> + <DropdownMenuSubContent> + <DropdownMenuRadioGroup + value={row.original.label} + onValueChange={(value) => { + startUpdateTransition(() => { + toast.promise( + modifiTask({ + id: row.original.id, + label: value as Task["label"], + }), + { + loading: "Updating...", + success: "Label updated", + error: (err) => getErrorMessage(err), + } + ) + }) + }} + > + {tasks.label.enumValues.map((label) => ( + <DropdownMenuRadioItem + key={label} + value={label} + className="capitalize" + disabled={isUpdatePending} + > + {label} + </DropdownMenuRadioItem> + ))} + </DropdownMenuRadioGroup> + </DropdownMenuSubContent> + </DropdownMenuSub> + <DropdownMenuSeparator /> + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "delete" })} + > + Delete + <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ) + }, + size: 40, + } + + // ---------------------------------------------------------------- + // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 + // ---------------------------------------------------------------- + // 3-1) groupMap: { [groupName]: ColumnDef<Task>[] } + const groupMap: Record<string, ColumnDef<Task>[]> = {} + + tasksColumnsConfig.forEach((cfg) => { + // 만약 group가 없으면 "_noGroup" 처리 + const groupName = cfg.group || "_noGroup" + + if (!groupMap[groupName]) { + groupMap[groupName] = [] + } + + // child column 정의 + const childCol: ColumnDef<Task> = { + accessorKey: cfg.id, + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeader column={column} title={cfg.label} /> + ), + meta: { + excelHeader: cfg.excelHeader, + group: cfg.group, + type: cfg.type, + }, + cell: ({ row, cell }) => { + // 예: cfg.id === "title" → custom rendering + if (cfg.id === "title") { + const labelVal = row.original.label + const labelExists = tasks.label.enumValues.includes(labelVal ?? "") + return ( + <div className="flex space-x-2"> + {labelExists && <Badge variant="outline">{labelVal}</Badge>} + <span className="max-w-[31.25rem] truncate font-medium"> + {row.getValue("title")} + </span> + </div> + ) + } + + if (cfg.id === "status") { + const statusVal = row.original.status + if (!statusVal) return null + const Icon = getStatusIcon(statusVal) + return ( + <div className="flex w-[6.25rem] items-center"> + <Icon className="mr-2 size-4 text-muted-foreground" aria-hidden="true" /> + <span className="capitalize">{statusVal}</span> + </div> + ) + } + + if (cfg.id === "priority") { + const priorityVal = row.original.priority + if (!priorityVal) return null + const Icon = getPriorityIcon(priorityVal) + return ( + <div className="flex items-center"> + <Icon className="mr-2 size-4 text-muted-foreground" aria-hidden="true" /> + <span className="capitalize">{priorityVal}</span> + </div> + ) + } + + if (cfg.id === "archived") { + return ( + <Badge variant="outline"> + {row.original.archived ? "Yes" : "No"} + </Badge> + ) + } + + if (cfg.id === "createdAt") { + const dateVal = cell.getValue() as Date + return formatDate(dateVal) + } + + // code etc... + return row.getValue(cfg.id) ?? "" + }, + } + + groupMap[groupName].push(childCol) + }) + + // ---------------------------------------------------------------- + // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기 + // ---------------------------------------------------------------- + const nestedColumns: ColumnDef<Task>[] = [] + + // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함 + // 여기서는 그냥 Object.entries 순서 + Object.entries(groupMap).forEach(([groupName, colDefs]) => { + if (groupName === "_noGroup") { + // 그룹 없음 → 그냥 최상위 레벨 컬럼 + nestedColumns.push(...colDefs) + } else { + // 상위 컬럼 + nestedColumns.push({ + id: groupName, + header: groupName, // "Basic Info", "Metadata" 등 + columns: colDefs, + }) + } + }) + + // ---------------------------------------------------------------- + // 4) 최종 컬럼 배열: select, nestedColumns, actions + // ---------------------------------------------------------------- + return [ + selectColumn, + ...nestedColumns, + actionsColumn, + ] +}
\ No newline at end of file diff --git a/lib/tasks/table/tasks-table-floating-bar.tsx b/lib/tasks/table/tasks-table-floating-bar.tsx new file mode 100644 index 00000000..6d367f81 --- /dev/null +++ b/lib/tasks/table/tasks-table-floating-bar.tsx @@ -0,0 +1,354 @@ +"use client" + +import * as React from "react" +import { tasks, type Task } from "@/db/schema/tasks" +import { SelectTrigger } from "@radix-ui/react-select" +import { type Table } from "@tanstack/react-table" +import { + ArrowUp, + CheckCircle2, + Download, + Loader, + Trash2, + X, +} from "lucide-react" +import { toast } from "sonner" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" +import { Portal } from "@/components/ui/portal" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, +} from "@/components/ui/select" +import { Separator } from "@/components/ui/separator" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" +import { Kbd } from "@/components/kbd" + +import { removeTasks, modifiTasks } from "@/lib//tasks/service" +import { DeleteTasksDialog } from "./delete-tasks-dialog" +import { ActionConfirmDialog } from "@/components/ui/action-dialog" + +interface TasksTableFloatingBarProps { + table: Table<Task> +} + + +export function TasksTableFloatingBar({ table }: TasksTableFloatingBarProps) { + const rows = table.getFilteredSelectedRowModel().rows + + const [isPending, startTransition] = React.useTransition() + const [action, setAction] = React.useState< + "update-status" | "update-priority" | "export" | "delete" + >() + const [popoverOpen, setPopoverOpen] = React.useState(false) + + // Clear selection on Escape key press + React.useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + table.toggleAllRowsSelected(false) + } + } + + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [table]) + + + + // 공용 confirm dialog state + const [confirmDialogOpen, setConfirmDialogOpen] = React.useState(false) + const [confirmProps, setConfirmProps] = React.useState<{ + title: string + description?: string + onConfirm: () => Promise<void> | void + }>({ + title: "", + description: "", + onConfirm: () => { }, + }) + + // 1) "삭제" Confirm 열기 + function handleDeleteConfirm() { + setAction("delete") + setConfirmProps({ + title: `Delete ${rows.length} user${rows.length > 1 ? "s" : ""}?`, + description: "This action cannot be undone.", + onConfirm: async () => { + startTransition(async () => { + const { error } = await removeTasks({ + ids: rows.map((row) => row.original.id), + }) + if (error) { + toast.error(error) + return + } + toast.success("Users deleted") + table.toggleAllRowsSelected(false) + setConfirmDialogOpen(false) + }) + }, + }) + setConfirmDialogOpen(true) + } + + // 2) + function handleSelectStatus(newStatus: Task["status"]) { + setAction("update-status") + + setConfirmProps({ + title: `Update ${rows.length} task${rows.length > 1 ? "s" : ""} with status: ${newStatus}?`, + description: "This action will override their current status.", + onConfirm: async () => { + startTransition(async () => { + const { error } = await modifiTasks({ + ids: rows.map((row) => row.original.id), + status: newStatus, + }) + if (error) { + toast.error(error) + return + } + toast.success("Tasks updated") + setConfirmDialogOpen(false) + }) + }, + }) + setConfirmDialogOpen(true) + } + + // 3) + function handleSelectPriority(newPriority: Task["priority"]) { + setAction("update-priority") + + setConfirmProps({ + title: `Update ${rows.length} task${rows.length > 1 ? "s" : ""} with priority: ${newPriority}?`, + description: "This action will override their current priority.", + onConfirm: async () => { + startTransition(async () => { + const { error } = await modifiTasks({ + ids: rows.map((row) => row.original.id), + priority: newPriority, + }) + if (error) { + toast.error(error) + return + } + toast.success("Tasks updated") + setConfirmDialogOpen(false) + }) + }, + }) + setConfirmDialogOpen(true) + } + + return ( + <Portal > + <div className="fixed inset-x-0 bottom-10 z-50 mx-auto w-fit px-2.5" style={{ bottom: '1.5rem' }}> + <div className="w-full overflow-x-auto"> + <div className="mx-auto flex w-fit items-center gap-2 rounded-md border bg-background p-2 text-foreground shadow"> + <div className="flex h-7 items-center rounded-md border border-dashed pl-2.5 pr-1"> + <span className="whitespace-nowrap text-xs"> + {rows.length} selected + </span> + <Separator orientation="vertical" className="ml-2 mr-1" /> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="icon" + className="size-5 hover:border" + onClick={() => table.toggleAllRowsSelected(false)} + > + <X className="size-3.5 shrink-0" aria-hidden="true" /> + </Button> + </TooltipTrigger> + <TooltipContent className="flex items-center border bg-accent px-2 py-1 font-semibold text-foreground dark:bg-zinc-900"> + <p className="mr-2">Clear selection</p> + <Kbd abbrTitle="Escape" variant="outline"> + Esc + </Kbd> + </TooltipContent> + </Tooltip> + </div> + <Separator orientation="vertical" className="hidden h-5 sm:block" /> + <div className="flex items-center gap-1.5"> + <Select + onValueChange={(value: Task["status"]) => { + handleSelectStatus(value) + }} + > + <Tooltip> + <SelectTrigger asChild> + <TooltipTrigger asChild> + <Button + variant="secondary" + size="icon" + className="size-7 border data-[state=open]:bg-accent data-[state=open]:text-accent-foreground" + disabled={isPending} + > + {isPending && action === "update-status" ? ( + <Loader + className="size-3.5 animate-spin" + aria-hidden="true" + /> + ) : ( + <CheckCircle2 + className="size-3.5" + aria-hidden="true" + /> + )} + </Button> + </TooltipTrigger> + </SelectTrigger> + <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> + <p>Update status</p> + </TooltipContent> + </Tooltip> + <SelectContent align="center"> + <SelectGroup> + {tasks.status.enumValues.map((status) => ( + <SelectItem + key={status} + value={status} + className="capitalize" + > + {status} + </SelectItem> + ))} + </SelectGroup> + </SelectContent> + </Select> + <Select + onValueChange={(value: Task["priority"]) => { + handleSelectPriority(value) + }} + > + <Tooltip> + <SelectTrigger asChild> + <TooltipTrigger asChild> + <Button + variant="secondary" + size="icon" + className="size-7 border data-[state=open]:bg-accent data-[state=open]:text-accent-foreground" + disabled={isPending} + > + {isPending && action === "update-priority" ? ( + <Loader + className="size-3.5 animate-spin" + aria-hidden="true" + /> + ) : ( + <ArrowUp className="size-3.5" aria-hidden="true" /> + )} + </Button> + </TooltipTrigger> + </SelectTrigger> + <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> + <p>Update priority</p> + </TooltipContent> + </Tooltip> + <SelectContent align="center"> + <SelectGroup> + {tasks.priority.enumValues.map((priority) => ( + <SelectItem + key={priority} + value={priority} + className="capitalize" + > + {priority} + </SelectItem> + ))} + </SelectGroup> + </SelectContent> + </Select> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="secondary" + size="icon" + className="size-7 border" + onClick={() => { + setAction("export") + + startTransition(() => { + exportTableToExcel(table, { + excludeColumns: ["select", "actions"], + onlySelected: true, + }) + }) + }} + disabled={isPending} + > + {isPending && action === "export" ? ( + <Loader + className="size-3.5 animate-spin" + aria-hidden="true" + /> + ) : ( + <Download className="size-3.5" aria-hidden="true" /> + )} + </Button> + </TooltipTrigger> + <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> + <p>Export tasks</p> + </TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="secondary" + size="icon" + className="size-7 border" + onClick={handleDeleteConfirm} + disabled={isPending} + > + {isPending && action === "delete" ? ( + <Loader + className="size-3.5 animate-spin" + aria-hidden="true" + /> + ) : ( + <Trash2 className="size-3.5" aria-hidden="true" /> + )} + </Button> + </TooltipTrigger> + <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> + <p>Delete tasks</p> + </TooltipContent> + </Tooltip> + </div> + </div> + </div> + </div> + + + {/* 공용 Confirm Dialog */} + <ActionConfirmDialog + open={confirmDialogOpen} + onOpenChange={setConfirmDialogOpen} + title={confirmProps.title} + description={confirmProps.description} + onConfirm={confirmProps.onConfirm} + isLoading={isPending && (action === "delete" || action === "update-priority" || action === "update-status")} + confirmLabel={ + action === "delete" + ? "Delete" + : action === "update-priority" || action === "update-status" + ? "Update" + : "Confirm" + } + confirmVariant={ + action === "delete" ? "destructive" : "default" + } + /> + </Portal> + ) +} diff --git a/lib/tasks/table/tasks-table-toolbar-actions.tsx b/lib/tasks/table/tasks-table-toolbar-actions.tsx new file mode 100644 index 00000000..8219b7b6 --- /dev/null +++ b/lib/tasks/table/tasks-table-toolbar-actions.tsx @@ -0,0 +1,117 @@ +"use client" + +import * as React from "react" +import { type Task } from "@/db/schema/tasks" +import { type Table } from "@tanstack/react-table" +import { Download, Upload } from "lucide-react" +import { toast } from "sonner" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" + +// 삭제, 추가 다이얼로그 +import { DeleteTasksDialog } from "./delete-tasks-dialog" +import { AddTaskDialog } from "./add-tasks-dialog" + +// 만약 서버 액션이나 API 라우트를 이용해 업로드 처리한다면 import +import { importTasksExcel } from "@/lib/tasks/service" // 예시 + +interface TasksTableToolbarActionsProps { + table: Table<Task> +} + +export function TasksTableToolbarActions({ table }: TasksTableToolbarActionsProps) { + // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식 + const fileInputRef = React.useRef<HTMLInputElement>(null) + + // 파일이 선택되었을 때 처리 + async function onFileChange(event: React.ChangeEvent<HTMLInputElement>) { + const file = event.target.files?.[0] + if (!file) return + + // 파일 초기화 (동일 파일 재업로드 시에도 onChange가 트리거되도록) + event.target.value = "" + + // 서버 액션 or API 호출 + try { + // 예: 서버 액션 호출 + const { errorFile, errorMessage } = await importTasksExcel(file) + + if (errorMessage) { + toast.error(errorMessage) + } + if (errorFile) { + // 에러 엑셀을 다운로드 + const url = URL.createObjectURL(errorFile) + const link = document.createElement("a") + link.href = url + link.download = "errors.xlsx" + link.click() + URL.revokeObjectURL(url) + } else { + // 성공 + toast.success("Import success") + // 필요 시 revalidateTag("tasks") 등 + } + + } catch (err) { + toast.error("파일 업로드 중 오류가 발생했습니다.") + + } + } + + function handleImportClick() { + // 숨겨진 <input type="file" /> 요소를 클릭 + fileInputRef.current?.click() + } + + return ( + <div className="flex items-center gap-2"> + {/** 1) 선택된 로우가 있으면 삭제 다이얼로그 */} + {table.getFilteredSelectedRowModel().rows.length > 0 ? ( + <DeleteTasksDialog + tasks={table + .getFilteredSelectedRowModel() + .rows.map((row) => row.original)} + onSuccess={() => table.toggleAllRowsSelected(false)} + /> + ) : null} + + {/** 2) 새 Task 추가 다이얼로그 */} + <AddTaskDialog /> + + {/** 3) Import 버튼 (파일 업로드) */} + <Button variant="outline" size="sm" className="gap-2" onClick={handleImportClick}> + <Upload className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Import</span> + </Button> + {/* + 실제로는 숨겨진 input과 연결: + - accept=".xlsx,.xls" 등으로 Excel 파일만 업로드 허용 + */} + <input + ref={fileInputRef} + type="file" + accept=".xlsx,.xls" + className="hidden" + onChange={onFileChange} + /> + + {/** 4) Export 버튼 */} + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(table, { + filename: "tasks", + excludeColumns: ["select", "actions"], + }) + } + className="gap-2" + > + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Export</span> + </Button> + </div> + ) +}
\ No newline at end of file diff --git a/lib/tasks/table/tasks-table.tsx b/lib/tasks/table/tasks-table.tsx new file mode 100644 index 00000000..ab448a7b --- /dev/null +++ b/lib/tasks/table/tasks-table.tsx @@ -0,0 +1,197 @@ +"use client" + +import * as React from "react" +import { tasks, type Task } from "@/db/schema/tasks" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" + +import { toSentenceCase } from "@/lib/utils" +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import { DataTableToolbar } from "@/components/data-table/data-table-toolbar" + +import type { + getTaskPriorityCounts, + getTasks, + getTaskStatusCounts, +} from "@/lib//tasks/service" +import { getPriorityIcon, getStatusIcon } from "@/lib/tasks/utils" +import { DeleteTasksDialog } from "./delete-tasks-dialog" +import { useFeatureFlags } from "./feature-flags-provider" +import { getColumns } from "./tasks-table-columns" +import { TasksTableFloatingBar } from "./tasks-table-floating-bar" +import { TasksTableToolbarActions } from "./tasks-table-toolbar-actions" +import { UpdateTaskSheet } from "./update-task-sheet" + +interface TasksTableProps { + promises: Promise< + [ + Awaited<ReturnType<typeof getTasks>>, + Awaited<ReturnType<typeof getTaskStatusCounts>>, + Awaited<ReturnType<typeof getTaskPriorityCounts>>, + ] + > +} + +export function TasksTable({ promises }: TasksTableProps) { + const { featureFlags } = useFeatureFlags() + + const [{ data, pageCount }, statusCounts, priorityCounts] = + React.use(promises) + + + + const [rowAction, setRowAction] = + React.useState<DataTableRowAction<Task> | null>(null) + + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + /** + * This component can render either a faceted filter or a search filter based on the `options` prop. + * + * @prop options - An array of objects, each representing a filter option. If provided, a faceted filter is rendered. If not, a search filter is rendered. + * + * Each `option` object has the following properties: + * @prop {string} label - The label for the filter option. + * @prop {string} value - The value for the filter option. + * @prop {React.ReactNode} [icon] - An optional icon to display next to the label. + * @prop {boolean} [withCount] - An optional boolean to display the count of the filter option. + */ + const filterFields: DataTableFilterField<Task>[] = [ + { + id: "title", + label: "Title", + placeholder: "Filter titles...", + }, + { + id: "status", + label: "Status", + options: tasks.status.enumValues.map((status) => ({ + label: toSentenceCase(status), + value: status, + icon: getStatusIcon(status), + count: statusCounts[status], + })), + }, + { + id: "priority", + label: "Priority", + options: tasks.priority.enumValues.map((priority) => ({ + label: toSentenceCase(priority), + value: priority, + icon: getPriorityIcon(priority), + count: priorityCounts[priority], + })), + }, + ] + + /** + * Advanced filter fields for the data table. + * These fields provide more complex filtering options compared to the regular filterFields. + * + * Key differences from regular filterFields: + * 1. More field types: Includes 'text', 'multi-select', 'date', and 'boolean'. + * 2. Enhanced flexibility: Allows for more precise and varied filtering options. + * 3. Used with DataTableAdvancedToolbar: Enables a more sophisticated filtering UI. + * 4. Date and boolean types: Adds support for filtering by date ranges and boolean values. + */ + const advancedFilterFields: DataTableAdvancedFilterField<Task>[] = [ + { + id: "code", + label: "Task", + type: "text", + }, + { + id: "title", + label: "Title", + type: "text", + }, + { + id: "label", + label: "Label", + type: "text", + }, + { + id: "status", + label: "Status", + type: "multi-select", + options: tasks.status.enumValues.map((status) => ({ + label: toSentenceCase(status), + value: status, + icon: getStatusIcon(status), + count: statusCounts[status], + })), + }, + { + id: "priority", + label: "Priority", + type: "multi-select", + options: tasks.priority.enumValues.map((priority) => ({ + label: toSentenceCase(priority), + value: priority, + icon: getPriorityIcon(priority), + count: priorityCounts[priority], + })), + }, + { + id: "createdAt", + label: "Created at", + type: "date", + }, + ] + + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => originalRow.id, + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + <DataTable + table={table} + floatingBar={<TasksTableFloatingBar table={table} />} + > + + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <TasksTableToolbarActions table={table} /> + </DataTableAdvancedToolbar> + + </DataTable> + <UpdateTaskSheet + open={rowAction?.type === "update"} + onOpenChange={() => setRowAction(null)} + task={rowAction?.row.original ?? null} + /> + <DeleteTasksDialog + open={rowAction?.type === "delete"} + onOpenChange={() => setRowAction(null)} + tasks={rowAction?.row.original ? [rowAction?.row.original] : []} + showTrigger={false} + onSuccess={() => rowAction?.row.toggleSelected(false)} + /> + </> + ) +} diff --git a/lib/tasks/table/update-task-sheet.tsx b/lib/tasks/table/update-task-sheet.tsx new file mode 100644 index 00000000..1f4f5aa8 --- /dev/null +++ b/lib/tasks/table/update-task-sheet.tsx @@ -0,0 +1,230 @@ +"use client" + +import * as React from "react" +import { tasks, type Task } from "@/db/schema/tasks" +import { zodResolver } from "@hookform/resolvers/zod" +import { Loader } from "lucide-react" +import { useForm } from "react-hook-form" +import { toast } from "sonner" + +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Textarea } from "@/components/ui/textarea" + +import { modifiTask } from "@/lib//tasks/service" +import { updateTaskSchema, type UpdateTaskSchema } from "@/lib/tasks/validations" + +interface UpdateTaskSheetProps + extends React.ComponentPropsWithRef<typeof Sheet> { + task: Task | null +} + +export function UpdateTaskSheet({ task, ...props }: UpdateTaskSheetProps) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + + const form = useForm<UpdateTaskSchema>({ + resolver: zodResolver(updateTaskSchema), + defaultValues: { + title: task?.title ?? "", + label: task?.label, + status: task?.status, + priority: task?.priority, + }, + }) + + function onSubmit(input: UpdateTaskSchema) { + startUpdateTransition(async () => { + if (!task) return + + const { error } = await modifiTask({ + id: task.id, + ...input, + }) + + if (error) { + toast.error(error) + return + } + + form.reset() + props.onOpenChange?.(false) + toast.success("Task updated") + }) + } + + return ( + <Sheet {...props}> + <SheetContent className="flex flex-col gap-6 sm:max-w-md"> + <SheetHeader className="text-left"> + <SheetTitle>Update task</SheetTitle> + <SheetDescription> + Update the task details and save the changes + </SheetDescription> + </SheetHeader> + <Form {...form}> + <form + onSubmit={form.handleSubmit(onSubmit)} + className="flex flex-col gap-4" + > + <FormField + control={form.control} + name="title" + render={({ field }) => ( + <FormItem> + <FormLabel>Title</FormLabel> + <FormControl> + <Textarea + placeholder="Do a kickflip" + className="resize-none" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="label" + render={({ field }) => ( + <FormItem> + <FormLabel>Label</FormLabel> + <Select + onValueChange={field.onChange} + defaultValue={field.value} + > + <FormControl> + <SelectTrigger className="capitalize"> + <SelectValue placeholder="Select a label" /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectGroup> + {tasks.label.enumValues.map((item) => ( + <SelectItem + key={item} + value={item} + className="capitalize" + > + {item} + </SelectItem> + ))} + </SelectGroup> + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="status" + render={({ field }) => ( + <FormItem> + <FormLabel>Status</FormLabel> + <Select + onValueChange={field.onChange} + defaultValue={field.value} + > + <FormControl> + <SelectTrigger className="capitalize"> + <SelectValue placeholder="Select a status" /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectGroup> + {tasks.status.enumValues.map((item) => ( + <SelectItem + key={item} + value={item} + className="capitalize" + > + {item} + </SelectItem> + ))} + </SelectGroup> + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="priority" + render={({ field }) => ( + <FormItem> + <FormLabel>Priority</FormLabel> + <Select + onValueChange={field.onChange} + defaultValue={field.value} + > + <FormControl> + <SelectTrigger className="capitalize"> + <SelectValue placeholder="Select a priority" /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectGroup> + {tasks.priority.enumValues.map((item) => ( + <SelectItem + key={item} + value={item} + className="capitalize" + > + {item} + </SelectItem> + ))} + </SelectGroup> + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + <SheetFooter className="gap-2 pt-2 sm:space-x-0"> + <SheetClose asChild> + <Button type="button" variant="outline"> + Cancel + </Button> + </SheetClose> + <Button disabled={isUpdatePending}> + {isUpdatePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + Save + </Button> + </SheetFooter> + </form> + </Form> + </SheetContent> + </Sheet> + ) +} diff --git a/lib/tasks/utils.ts b/lib/tasks/utils.ts new file mode 100644 index 00000000..ea4425de --- /dev/null +++ b/lib/tasks/utils.ts @@ -0,0 +1,80 @@ +import { tasks, type Task } from "@/db/schema/tasks" +import { faker } from "@faker-js/faker" +import { + ArrowDownIcon, + ArrowRightIcon, + ArrowUpIcon, + AwardIcon, + CheckCircle2, + CircleHelp, + CircleIcon, + CircleX, + PencilIcon, + SearchIcon, + SendIcon, + Timer, +} from "lucide-react" +import { customAlphabet } from "nanoid" + +import { generateId } from "@/lib/id" +import { Rfq } from "@/db/schema/rfq" + +export function generateRandomTask(): Task { + return { + id: generateId("task"), + code: `TASK-${customAlphabet("0123456789", 4)()}`, + title: faker.hacker + .phrase() + .replace(/^./, (letter) => letter.toUpperCase()), + status: faker.helpers.shuffle(tasks.status.enumValues)[0] ?? "todo", + label: faker.helpers.shuffle(tasks.label.enumValues)[0] ?? "bug", + priority: faker.helpers.shuffle(tasks.priority.enumValues)[0] ?? "low", + archived: faker.datatype.boolean({ probability: 0.2 }), + createdAt: new Date(), + updatedAt: new Date(), + } +} + +/** + * Returns the appropriate status icon based on the provided status. + * @param status - The status of the task. + * @returns A React component representing the status icon. + */ +export function getStatusIcon(status: Task["status"]) { + const statusIcons = { + canceled: CircleX, + done: CheckCircle2, + "in-progress": Timer, + todo: CircleHelp, + } + + return statusIcons[status] || CircleIcon +} + +export function getRFQStatusIcon(status: Rfq["status"]) { + const statusIcons = { + DRAFT: PencilIcon, + PUBLISHED: SendIcon, + EVALUATION: SearchIcon, + AWARDED: AwardIcon, + } + + + + return statusIcons[status] || CircleIcon +} + +/** + * Returns the appropriate priority icon based on the provided priority. + * @param priority - The priority of the task. + * @returns A React component representing the priority icon. + */ +export function getPriorityIcon(priority: Task["priority"]) { + const priorityIcons = { + high: ArrowUpIcon, + low: ArrowDownIcon, + medium: ArrowRightIcon, + } + + return priorityIcons[priority] || CircleIcon +} diff --git a/lib/tasks/validations.ts b/lib/tasks/validations.ts new file mode 100644 index 00000000..fea313f3 --- /dev/null +++ b/lib/tasks/validations.ts @@ -0,0 +1,50 @@ +import { tasks, type Task } from "@/db/schema/tasks"; +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, +} from "nuqs/server" +import * as z from "zod" + +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" + +export const searchParamsCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault( + [] + ), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser<Task>().withDefault([ + { id: "createdAt", desc: true }, + ]), + title: parseAsString.withDefault(""), + status: parseAsArrayOf(z.enum(tasks.status.enumValues)).withDefault([]), + priority: parseAsArrayOf(z.enum(tasks.priority.enumValues)).withDefault([]), + from: parseAsString.withDefault(""), + to: parseAsString.withDefault(""), + // advanced filter + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), + +}) + +export const createTaskSchema = z.object({ + title: z.string(), + label: z.enum(tasks.label.enumValues), + status: z.enum(tasks.status.enumValues), + priority: z.enum(tasks.priority.enumValues), +}) + +export const updateTaskSchema = z.object({ + title: z.string().optional(), + label: z.enum(tasks.label.enumValues).optional(), + status: z.enum(tasks.status.enumValues).optional(), + priority: z.enum(tasks.priority.enumValues).optional(), +}) + +export type GetTasksSchema = Awaited<ReturnType<typeof searchParamsCache.parse>> +export type CreateTaskSchema = z.infer<typeof createTaskSchema> +export type UpdateTaskSchema = z.infer<typeof updateTaskSchema> diff --git a/lib/tbe/service.ts b/lib/tbe/service.ts new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/lib/tbe/service.ts diff --git a/lib/tbe/table/comments-sheet.tsx b/lib/tbe/table/comments-sheet.tsx new file mode 100644 index 00000000..7fcde35d --- /dev/null +++ b/lib/tbe/table/comments-sheet.tsx @@ -0,0 +1,334 @@ +"use client" + +import * as React from "react" +import { useForm, useFieldArray } from "react-hook-form" +import { z } from "zod" +import { zodResolver } from "@hookform/resolvers/zod" +import { Loader, Download, X } from "lucide-react" +import prettyBytes from "pretty-bytes" +import { toast } from "sonner" + +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Textarea, +} from "@/components/ui/textarea" + +import { + Dropzone, + DropzoneZone, + DropzoneUploadIcon, + DropzoneTitle, + DropzoneDescription, + DropzoneInput +} from "@/components/ui/dropzone" + +import { + Table, + TableHeader, + TableRow, + TableHead, + TableBody, + TableCell +} from "@/components/ui/table" + +// DB 스키마에서 필요한 타입들을 가져온다고 가정 +// (실제 프로젝트에 맞춰 import를 수정하세요.) +import { RfqWithAll } from "@/db/schema/rfq" +import { formatDate } from "@/lib/utils" +import { createRfqCommentWithAttachments } from "@/lib/rfqs/service" + +// 코멘트 + 첨부파일 구조 (단순 예시) +// 실제 DB 스키마에 맞춰 조정 +export interface TbeComment { + id: number + commentText: string + commentedBy?: number + createdAt?: string | Date + attachments?: { + id: number + fileName: string + filePath: string + }[] +} + +interface CommentSheetProps extends React.ComponentPropsWithRef<typeof Sheet> { + /** 코멘트를 작성할 RFQ 정보 */ + /** 이미 존재하는 모든 코멘트 목록 (서버에서 불러와 주입) */ + initialComments?: TbeComment[] + + /** 사용자(작성자) ID (로그인 세션 등에서 가져옴) */ + currentUserId: number + rfqId:number + vendorId:number + /** 댓글 저장 후 갱신용 콜백 (옵션) */ + onCommentsUpdated?: (comments: TbeComment[]) => void +} + +// 새 코멘트 작성 폼 스키마 +const commentFormSchema = z.object({ + commentText: z.string().min(1, "댓글을 입력하세요."), + newFiles: z.array(z.any()).optional() // File[] +}) +type CommentFormValues = z.infer<typeof commentFormSchema> + +const MAX_FILE_SIZE = 30e6 // 30MB + +export function CommentSheet({ + rfqId, + vendorId, + initialComments = [], + currentUserId, + onCommentsUpdated, + ...props +}: CommentSheetProps) { + const [comments, setComments] = React.useState<TbeComment[]>(initialComments) + const [isPending, startTransition] = React.useTransition() + + React.useEffect(() => { + setComments(initialComments) + }, [initialComments]) + + + // RHF 세팅 + const form = useForm<CommentFormValues>({ + resolver: zodResolver(commentFormSchema), + defaultValues: { + commentText: "", + newFiles: [] + } + }) + + // formFieldArray 예시 (파일 목록) + const { fields: newFileFields, append, remove } = useFieldArray({ + control: form.control, + name: "newFiles" + }) + + // 1) 기존 코멘트 + 첨부 보여주기 + // 간단히 테이블 하나로 표현 + // 실제로는 Bubble 형태의 UI, Accordion, Timeline 등 다양하게 구성할 수 있음 + function renderExistingComments() { + if (comments.length === 0) { + return <p className="text-sm text-muted-foreground">No comments yet</p> + } + + return ( + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-1/2">Comment</TableHead> + <TableHead>Attachments</TableHead> + <TableHead>Created At</TableHead> + <TableHead>Created By</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {comments.map((c) => ( + <TableRow key={c.id}> + <TableCell>{c.commentText}</TableCell> + <TableCell> + {/* 첨부파일 표시 */} + {(!c.attachments || c.attachments.length === 0) && ( + <span className="text-sm text-muted-foreground">No files</span> + )} + {c.attachments && c.attachments.length > 0 && ( + <div className="flex flex-col gap-1"> + {c.attachments.map((att) => ( + <div key={att.id} className="flex items-center gap-2"> + <a + href={att.filePath} + download + target="_blank" + rel="noreferrer" + className="inline-flex items-center gap-1 text-blue-600 underline" + > + <Download className="h-4 w-4" /> + {att.fileName} + </a> + </div> + ))} + </div> + )} + </TableCell> + <TableCell> { c.createdAt ? formatDate(c.createdAt): "-"}</TableCell> + <TableCell> + {c.commentedBy ?? "-"} + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + ) + } + + // 2) 새 파일 Drop + function handleDropAccepted(files: File[]) { + // 드롭된 File[]을 RHF field array에 추가 + const toAppend = files.map((f) => f) + append(toAppend) + } + + + // 3) 저장(Submit) + async function onSubmit(data: CommentFormValues) { + + if (!rfqId) return + startTransition(async () => { + try { + // 서버 액션 호출 + const res = await createRfqCommentWithAttachments({ + rfqId: rfqId, + vendorId: vendorId, // 필요시 세팅 + commentText: data.commentText, + commentedBy: currentUserId, + evaluationId: null, // 필요시 세팅 + files: data.newFiles + }) + + if (!res.ok) { + throw new Error("Failed to create comment") + } + + toast.success("Comment created") + + // 새 코멘트를 다시 불러오거나, + // 여기서는 임시로 "새로운 코멘트가 추가됐다" 라고 가정하여 클라이언트에서 상태 업데이트 + const newComment: TbeComment = { + id: res.commentId, // 서버에서 반환된 commentId + commentText: data.commentText, + commentedBy: currentUserId, + createdAt: new Date().toISOString(), + attachments: (data.newFiles?.map((f, idx) => ({ + id: Math.random() * 100000, + fileName: f.name, + filePath: "/uploads/" + f.name, + })) || []) + } + setComments((prev) => [...prev, newComment]) + onCommentsUpdated?.([...comments, newComment]) + + // 폼 리셋 + form.reset() + } catch (err: any) { + console.error(err) + toast.error("Error: " + err.message) + } + }) + } + + return ( + <Sheet {...props}> + <SheetContent className="flex flex-col gap-6 sm:max-w-lg"> + <SheetHeader className="text-left"> + <SheetTitle>Comments</SheetTitle> + <SheetDescription> + 필요시 첨부파일과 함께 문의/코멘트를 남길 수 있습니다. + </SheetDescription> + </SheetHeader> + + {/* 기존 코멘트 목록 */} + <div className="max-h-[300px] overflow-y-auto"> + {renderExistingComments()} + </div> + + {/* 새 코멘트 작성 Form */} + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4"> + <FormField + control={form.control} + name="commentText" + render={({ field }) => ( + <FormItem> + <FormLabel>New Comment</FormLabel> + <FormControl> + <Textarea + placeholder="Enter your comment..." + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Dropzone (파일 첨부) */} + <Dropzone + maxSize={MAX_FILE_SIZE} + onDropAccepted={handleDropAccepted} + onDropRejected={(rej) => { + toast.error("File rejected: " + (rej[0]?.file?.name || "")) + }} + > + {({ maxSize }) => ( + <DropzoneZone className="flex justify-center"> + <DropzoneInput /> + <div className="flex items-center gap-6"> + <DropzoneUploadIcon /> + <div className="grid gap-0.5"> + <DropzoneTitle>Drop to attach files</DropzoneTitle> + <DropzoneDescription> + Max size: {prettyBytes(maxSize || 0)} + </DropzoneDescription> + </div> + </div> + </DropzoneZone> + )} + </Dropzone> + + {/* 선택된 파일 목록 */} + {newFileFields.length > 0 && ( + <div className="flex flex-col gap-2"> + {newFileFields.map((field, idx) => { + const file = form.getValues(`newFiles.${idx}`) + if (!file) return null + return ( + <div key={field.id} className="flex items-center justify-between border rounded p-2"> + <span className="text-sm">{file.name} ({prettyBytes(file.size)})</span> + <Button + variant="ghost" + size="icon" + type="button" + onClick={() => remove(idx)} + > + <X className="h-4 w-4" /> + </Button> + </div> + ) + })} + </div> + )} + + <SheetFooter className="gap-2 pt-4"> + <SheetClose asChild> + <Button type="button" variant="outline"> + Cancel + </Button> + </SheetClose> + <Button disabled={isPending}> + {isPending && <Loader className="mr-2 h-4 w-4 animate-spin" />} + Save + </Button> + </SheetFooter> + </form> + </Form> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/lib/tbe/table/feature-flags-provider.tsx b/lib/tbe/table/feature-flags-provider.tsx new file mode 100644 index 00000000..81131894 --- /dev/null +++ b/lib/tbe/table/feature-flags-provider.tsx @@ -0,0 +1,108 @@ +"use client" + +import * as React from "react" +import { useQueryState } from "nuqs" + +import { dataTableConfig, type DataTableConfig } from "@/config/data-table" +import { cn } from "@/lib/utils" +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" + +type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"] + +interface FeatureFlagsContextProps { + featureFlags: FeatureFlagValue[] + setFeatureFlags: (value: FeatureFlagValue[]) => void +} + +const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({ + featureFlags: [], + setFeatureFlags: () => {}, +}) + +export function useFeatureFlags() { + const context = React.useContext(FeatureFlagsContext) + if (!context) { + throw new Error( + "useFeatureFlags must be used within a FeatureFlagsProvider" + ) + } + return context +} + +interface FeatureFlagsProviderProps { + children: React.ReactNode +} + +export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) { + const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>( + "flags", + { + defaultValue: [], + parse: (value) => value.split(",") as FeatureFlagValue[], + serialize: (value) => value.join(","), + eq: (a, b) => + a.length === b.length && a.every((value, index) => value === b[index]), + clearOnDefault: true, + shallow: false, + } + ) + + return ( + <FeatureFlagsContext.Provider + value={{ + featureFlags, + setFeatureFlags: (value) => void setFeatureFlags(value), + }} + > + <div className="w-full overflow-x-auto"> + <ToggleGroup + type="multiple" + variant="outline" + size="sm" + value={featureFlags} + onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)} + className="w-fit gap-0" + > + {dataTableConfig.featureFlags.map((flag, index) => ( + <Tooltip key={flag.value}> + <ToggleGroupItem + value={flag.value} + className={cn( + "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90", + { + "rounded-l-sm border-r-0": index === 0, + "rounded-r-sm": + index === dataTableConfig.featureFlags.length - 1, + } + )} + asChild + > + <TooltipTrigger> + <flag.icon className="size-3.5 shrink-0" aria-hidden="true" /> + {flag.label} + </TooltipTrigger> + </ToggleGroupItem> + <TooltipContent + align="start" + side="bottom" + sideOffset={6} + className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground" + > + <div>{flag.tooltipTitle}</div> + <div className="text-xs text-muted-foreground"> + {flag.tooltipDescription} + </div> + </TooltipContent> + </Tooltip> + ))} + </ToggleGroup> + </div> + {children} + </FeatureFlagsContext.Provider> + ) +} diff --git a/lib/tbe/table/file-dialog.tsx b/lib/tbe/table/file-dialog.tsx new file mode 100644 index 00000000..b569f2b1 --- /dev/null +++ b/lib/tbe/table/file-dialog.tsx @@ -0,0 +1,141 @@ +"use client" + +import * as React from "react" +import { Download, X } from "lucide-react" +import { toast } from "sonner" + +import { getErrorMessage } from "@/lib/handle-error" +import { formatDateTime } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" + +import { + FileList, + FileListItem, + FileListIcon, + FileListInfo, + FileListName, + FileListDescription, + FileListAction, +} from "@/components/ui/file-list" +import { getTbeFilesForVendor } from "@/lib/rfqs/service" + +interface TBEFileDialogProps { + isOpen: boolean + onOpenChange: (open: boolean) => void + tbeId: number + vendorId: number + rfqId: number + onRefresh?: () => void +} + +export function TBEFileDialog({ + isOpen, + onOpenChange, + vendorId, + rfqId, + onRefresh, +}: TBEFileDialogProps) { + const [submittedFiles, setSubmittedFiles] = React.useState<any[]>([]) + const [isFetchingFiles, setIsFetchingFiles] = React.useState(false) + + + // Fetch submitted files when dialog opens + React.useEffect(() => { + if (isOpen && rfqId && vendorId) { + fetchSubmittedFiles() + } + }, [isOpen, rfqId, vendorId]) + + // Fetch submitted files using the service function + const fetchSubmittedFiles = async () => { + if (!rfqId || !vendorId) return + + setIsFetchingFiles(true) + try { + const { files, error } = await getTbeFilesForVendor(rfqId, vendorId) + + if (error) { + throw new Error(error) + } + + setSubmittedFiles(files) + } catch (error) { + toast.error("Failed to load files: " + getErrorMessage(error)) + } finally { + setIsFetchingFiles(false) + } + } + + // Download submitted file + const downloadSubmittedFile = async (file: any) => { + try { + const response = await fetch(`/api/file/${file.id}/download`) + if (!response.ok) { + throw new Error("Failed to download file") + } + + const blob = await response.blob() + const url = window.URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = file.fileName + document.body.appendChild(a) + a.click() + window.URL.revokeObjectURL(url) + document.body.removeChild(a) + } catch (error) { + toast.error("Failed to download file: " + getErrorMessage(error)) + } + } + + return ( + <Dialog open={isOpen} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-lg"> + <DialogHeader> + <DialogTitle>TBE 응답 파일</DialogTitle> + <DialogDescription>제출된 파일 목록을 확인하고 다운로드하세요.</DialogDescription> + </DialogHeader> + + {/* 제출된 파일 목록 */} + {isFetchingFiles ? ( + <div className="flex justify-center items-center py-8"> + <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div> + </div> + ) : submittedFiles.length > 0 ? ( + <div className="grid gap-2"> + <FileList> + {submittedFiles.map((file) => ( + <FileListItem key={file.id} className="flex items-center justify-between gap-3"> + <div className="flex items-center gap-3 flex-1"> + <FileListIcon className="flex-shrink-0" /> + <FileListInfo className="flex-1 min-w-0"> + <FileListName className="text-sm font-medium truncate">{file.fileName}</FileListName> + <FileListDescription className="text-xs text-muted-foreground"> + {file.uploadedAt ? formatDateTime(file.uploadedAt) : ""} + </FileListDescription> + </FileListInfo> + </div> + <FileListAction className="flex-shrink-0 ml-2"> + <Button variant="ghost" size="icon" onClick={() => downloadSubmittedFile(file)}> + <Download className="h-4 w-4" /> + <span className="sr-only">파일 다운로드</span> + </Button> + </FileListAction> + </FileListItem> + ))} + </FileList> + </div> + ) : ( + <div className="text-center py-8 text-muted-foreground">제출된 파일이 없습니다.</div> + )} + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/tbe/table/invite-vendors-dialog.tsx b/lib/tbe/table/invite-vendors-dialog.tsx new file mode 100644 index 00000000..87467e57 --- /dev/null +++ b/lib/tbe/table/invite-vendors-dialog.tsx @@ -0,0 +1,203 @@ +"use client" + +import * as React from "react" +import { type Row } from "@tanstack/react-table" +import { Loader, Send } from "lucide-react" +import { toast } from "sonner" + +import { useMediaQuery } from "@/hooks/use-media-query" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer" + +import { Input } from "@/components/ui/input" + +import { VendorWithTbeFields } from "@/config/vendorTbeColumnsConfig" +import { inviteTbeVendorsAction } from "@/lib/rfqs/service" + +interface InviteVendorsDialogProps + extends React.ComponentPropsWithoutRef<typeof Dialog> { + vendors: Row<VendorWithTbeFields>["original"][] + rfqId: number + showTrigger?: boolean + onSuccess?: () => void +} + +export function InviteVendorsDialog({ + vendors, + rfqId, + showTrigger = true, + onSuccess, + ...props +}: InviteVendorsDialogProps) { + const [isInvitePending, startInviteTransition] = React.useTransition() + + + // multiple 파일을 받을 state + const [files, setFiles] = React.useState<FileList | null>(null) + + // 미디어쿼리 (desktop 여부) + const isDesktop = useMediaQuery("(min-width: 640px)") + + function onInvite() { + startInviteTransition(async () => { + // 파일이 선택되지 않았다면 에러 + if (!files || files.length === 0) { + toast.error("Please attach TBE files before inviting.") + return + } + + // FormData 생성 + const formData = new FormData() + formData.append("rfqId", String(rfqId)) + vendors.forEach((vendor) => { + formData.append("vendorIds[]", String(vendor.id)) + }) + + // multiple 파일 + for (let i = 0; i < files.length; i++) { + formData.append("tbeFiles", files[i]) // key는 동일하게 "tbeFiles" + } + + // 서버 액션 호출 + const { error } = await inviteTbeVendorsAction(formData) + + if (error) { + toast.error(error) + return + } + + // 성공 + props.onOpenChange?.(false) + toast.success("Vendors invited with TBE!") + onSuccess?.() + }) + } + + // 파일 선택 UI + const fileInput = ( + <div className="mb-4"> + <label className="mb-2 block font-medium">TBE Sheets</label> + <Input + type="file" + multiple + onChange={(e) => { + setFiles(e.target.files) + }} + /> + </div> + ) + + // Desktop Dialog + if (isDesktop) { + return ( + <Dialog {...props}> + {showTrigger ? ( + <DialogTrigger asChild> + <Button variant="outline" size="sm"> + <Send className="mr-2 size-4" aria-hidden="true" /> + Invite ({vendors.length}) + </Button> + </DialogTrigger> + ) : null} + <DialogContent> + <DialogHeader> + <DialogTitle>Are you absolutely sure?</DialogTitle> + <DialogDescription> + This action cannot be undone. This will permanently invite{" "} + <span className="font-medium">{vendors.length}</span> + {vendors.length === 1 ? " vendor" : " vendors"}. 파일 첨부가 필수이므로 파일을 첨부해야지 버튼이 활성화됩니다. + </DialogDescription> + </DialogHeader> + + {/* 파일 첨부 */} + {fileInput} + + <DialogFooter className="gap-2 sm:space-x-0"> + <DialogClose asChild> + <Button variant="outline">Cancel</Button> + </DialogClose> + <Button + aria-label="Invite selected rows" + variant="destructive" + onClick={onInvite} + // 파일이 없거나 초대 진행중이면 비활성화 + disabled={isInvitePending || !files || files.length === 0} + > + {isInvitePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + Invite + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) + } + + // Mobile Drawer + return ( + <Drawer {...props}> + {showTrigger ? ( + <DrawerTrigger asChild> + <Button variant="outline" size="sm"> + <Send className="mr-2 size-4" aria-hidden="true" /> + Invite ({vendors.length}) + </Button> + </DrawerTrigger> + ) : null} + <DrawerContent> + <DrawerHeader> + <DrawerTitle>Are you absolutely sure?</DrawerTitle> + <DrawerDescription> + This action cannot be undone. This will permanently invite{" "} + <span className="font-medium">{vendors.length}</span> + {vendors.length === 1 ? " vendor" : " vendors"}. + </DrawerDescription> + </DrawerHeader> + + {/* 파일 첨부 */} + {fileInput} + + <DrawerFooter className="gap-2 sm:space-x-0"> + <DrawerClose asChild> + <Button variant="outline">Cancel</Button> + </DrawerClose> + <Button + aria-label="Invite selected rows" + variant="destructive" + onClick={onInvite} + // 파일이 없거나 초대 진행중이면 비활성화 + disabled={isInvitePending || !files || files.length === 0} + > + {isInvitePending && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + Invite + </Button> + </DrawerFooter> + </DrawerContent> + </Drawer> + ) +}
\ No newline at end of file diff --git a/lib/tbe/table/tbe-table-columns.tsx b/lib/tbe/table/tbe-table-columns.tsx new file mode 100644 index 00000000..f2bc2ced --- /dev/null +++ b/lib/tbe/table/tbe-table-columns.tsx @@ -0,0 +1,249 @@ +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { Download, Ellipsis, MessageSquare } from "lucide-react" +import { toast } from "sonner" + +import { getErrorMessage } from "@/lib/handle-error" +import { formatDate } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { useRouter } from "next/navigation" + +import { + VendorTbeColumnConfig, + vendorTbeColumnsConfig, + VendorWithTbeFields, +} from "@/config/vendorTbeColumnsConfig" + +type NextRouter = ReturnType<typeof useRouter> + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<VendorWithTbeFields> | null>> + router: NextRouter + openCommentSheet: (vendorId: number, rfqId: number) => void + openFilesDialog: (tbeId: number, vendorId: number, rfqId: number) => void +} + + +/** + * tanstack table 컬럼 정의 (중첩 헤더 버전) + */ +export function getColumns({ + setRowAction, + router, + openCommentSheet, + openFilesDialog +}: GetColumnsProps): ColumnDef<VendorWithTbeFields>[] { + // ---------------------------------------------------------------- + // 1) Select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef<VendorWithTbeFields> = { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + } + + // ---------------------------------------------------------------- + // 2) 그룹화(Nested) 컬럼 구성 + // ---------------------------------------------------------------- + const groupMap: Record<string, ColumnDef<VendorWithTbeFields>[]> = {} + + vendorTbeColumnsConfig.forEach((cfg) => { + const groupName = cfg.group || "_noGroup" + if (!groupMap[groupName]) { + groupMap[groupName] = [] + } + + // childCol: ColumnDef<VendorWithTbeFields> + const childCol: ColumnDef<VendorWithTbeFields> = { + accessorKey: cfg.id, + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title={cfg.label} /> + ), + meta: { + excelHeader: cfg.excelHeader, + group: cfg.group, + type: cfg.type, + }, + // 셀 렌더링 + cell: ({ row, getValue }) => { + // 1) 필드값 가져오기 + const val = getValue() + + if (cfg.id === "vendorStatus") { + const statusVal = row.original.vendorStatus + if (!statusVal) return null + // const Icon = getStatusIcon(statusVal) + return ( + <Badge variant="outline"> + {statusVal} + </Badge> + ) + } + + + if (cfg.id === "rfqVendorStatus") { + const statusVal = row.original.rfqVendorStatus + if (!statusVal) return null + // const Icon = getStatusIcon(statusVal) + const variant = statusVal ==="INVITED"?"default" :statusVal ==="DECLINED"?"destructive":statusVal ==="ACCEPTED"?"secondary":"outline" + return ( + <Badge variant={variant}> + {statusVal} + </Badge> + ) + } + + // 예) TBE Updated (날짜) + if (cfg.id === "tbeUpdated") { + const dateVal = val as Date | undefined + if (!dateVal) return null + return formatDate(dateVal) + } + + // 그 외 필드는 기본 값 표시 + return val ?? "" + }, + } + + groupMap[groupName].push(childCol) + }) + + // groupMap → nestedColumns + const nestedColumns: ColumnDef<VendorWithTbeFields>[] = [] + Object.entries(groupMap).forEach(([groupName, colDefs]) => { + if (groupName === "_noGroup") { + nestedColumns.push(...colDefs) + } else { + nestedColumns.push({ + id: groupName, + header: groupName, + columns: colDefs, + }) + } + }) +// 파일 칼럼 +const filesColumn: ColumnDef<VendorWithTbeFields> = { + id: "files", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Response Files" /> + ), + cell: ({ row }) => { + const vendor = row.original + const filesCount = vendor.files?.length ?? 0 + + function handleClick() { + // setRowAction으로 타입만 설정하고 끝내는 방법도 가능하지만 + // 혹은 바로 openFilesDialog()를 호출해도 됨. + setRowAction({ row, type: "files" }) + // 필요한 값을 직접 호출해서 넘겨줄 수도 있음. + openFilesDialog( + vendor.tbeId ?? 0, + vendor.vendorId ?? 0, + vendor.rfqId ?? 0, + ) + } + + return ( + <Button + variant="ghost" + size="sm" + className="relative h-8 w-8 p-0 group" + onClick={handleClick} + aria-label={filesCount > 0 ? `View ${filesCount} files` : "Upload file"} + > + <Download className="h-4 w-4" /> + {filesCount > 0 && ( + <Badge variant="secondary" className="absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center"> + {filesCount} + </Badge> + )} + </Button> + ) + }, + enableSorting: false, + maxSize: 80, +} + +// 댓글 칼럼 +const commentsColumn: ColumnDef<VendorWithTbeFields> = { + id: "comments", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Comments" /> + ), + cell: ({ row }) => { + const vendor = row.original + const commCount = vendor.comments?.length ?? 0 + + function handleClick() { + // setRowAction() 로 type 설정 + setRowAction({ row, type: "comments" }) + // 필요하면 즉시 openCommentSheet() 직접 호출 + openCommentSheet( + vendor.vendorId ?? 0, + vendor.rfqId ?? 0, + ) + } + + return ( + <Button variant="ghost" size="sm" className="h-8 w-8 p-0 group relative" onClick={handleClick}> + <MessageSquare className="h-4 w-4" /> + {commCount > 0 && ( + <Badge variant="secondary" className="absolute -top-1 -right-1 h-4 min-w-[1rem] text-[0.625rem] p-0 flex items-center justify-center"> + {commCount} + </Badge> + )} + </Button> + ) + }, + enableSorting: false, + maxSize: 80, +} +// ---------------------------------------------------------------- +// 5) 최종 컬럼 배열 - Update to include the files column +// ---------------------------------------------------------------- +return [ + selectColumn, + ...nestedColumns, + filesColumn, // Add the files column before comments + commentsColumn, + // actionsColumn, +] + +}
\ No newline at end of file diff --git a/lib/tbe/table/tbe-table-toolbar-actions.tsx b/lib/tbe/table/tbe-table-toolbar-actions.tsx new file mode 100644 index 00000000..6a336135 --- /dev/null +++ b/lib/tbe/table/tbe-table-toolbar-actions.tsx @@ -0,0 +1,60 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Download, Upload } from "lucide-react" +import { toast } from "sonner" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" + + +import { InviteVendorsDialog } from "./invite-vendors-dialog" +import { VendorWithTbeFields } from "@/config/vendorTbeColumnsConfig" + +interface VendorsTableToolbarActionsProps { + table: Table<VendorWithTbeFields> + rfqId: number +} + +export function VendorsTableToolbarActions({ table,rfqId }: VendorsTableToolbarActionsProps) { + // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식 + const fileInputRef = React.useRef<HTMLInputElement>(null) + + // 파일이 선택되었을 때 처리 + + function handleImportClick() { + // 숨겨진 <input type="file" /> 요소를 클릭 + fileInputRef.current?.click() + } + + return ( + <div className="flex items-center gap-2"> + {table.getFilteredSelectedRowModel().rows.length > 0 ? ( + <InviteVendorsDialog + vendors={table + .getFilteredSelectedRowModel() + .rows.map((row) => row.original)} + rfqId = {rfqId} + onSuccess={() => table.toggleAllRowsSelected(false)} + /> + ) : null} + + + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(table, { + filename: "tasks", + excludeColumns: ["select", "actions"], + }) + } + className="gap-2" + > + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Export</span> + </Button> + </div> + ) +}
\ No newline at end of file diff --git a/lib/tbe/table/tbe-table.tsx b/lib/tbe/table/tbe-table.tsx new file mode 100644 index 00000000..ed323800 --- /dev/null +++ b/lib/tbe/table/tbe-table.tsx @@ -0,0 +1,204 @@ +"use client" + +import * as React from "react" +import { useRouter } from "next/navigation" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" + +import { toSentenceCase } from "@/lib/utils" +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import { useFeatureFlags } from "./feature-flags-provider" +import { getColumns } from "./tbe-table-columns" +import { Vendor, vendors } from "@/db/schema/vendors" +import { InviteVendorsDialog } from "./invite-vendors-dialog" +import { CommentSheet, TbeComment } from "./comments-sheet" +import { VendorWithTbeFields } from "@/config/vendorTbeColumnsConfig" +import { TBEFileDialog } from "./file-dialog" +import { fetchRfqAttachmentsbyCommentId, getAllTBE } from "@/lib/rfqs/service" +import { VendorsTableToolbarActions } from "./tbe-table-toolbar-actions" + +interface VendorsTableProps { + promises: Promise<[ + Awaited<ReturnType<typeof getAllTBE>>, + ]> +} + +export function AllTbeTable({ promises }: VendorsTableProps) { + const { featureFlags } = useFeatureFlags() + const router = useRouter() + + // Suspense로 받아온 데이터 + const [{ data, pageCount }] = React.use(promises) + + const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorWithTbeFields> | null>(null) + + // 댓글 시트 관련 state + const [initialComments, setInitialComments] = React.useState<TbeComment[]>([]) + const [commentSheetOpen, setCommentSheetOpen] = React.useState(false) + const [selectedVendorIdForComments, setSelectedVendorIdForComments] = React.useState<number | null>(null) + const [selectedRfqIdForComments, setSelectedRfqIdForComments] = React.useState<number | null>(null) + + // 파일 다이얼로그 관련 state + const [isFileDialogOpen, setIsFileDialogOpen] = React.useState(false) + const [selectedVendorIdForFiles, setSelectedVendorIdForFiles] = React.useState<number | null>(null) + const [selectedTbeIdForFiles, setSelectedTbeIdForFiles] = React.useState<number | null>(null) + const [selectedRfqIdForFiles, setSelectedRfqIdForFiles] = React.useState<number | null>(null) + + // 테이블 리프레시용 + const handleRefresh = React.useCallback(() => { + router.refresh(); + }, [router]); + + // ----------------------------------------------------------- + // 특정 action이 설정될 때마다 실행되는 effect + // ----------------------------------------------------------- + React.useEffect(() => { + if (!rowAction) return + + if (rowAction.type === "comments") { + // rowAction가 새로 세팅되면 openCommentSheet 실행 + // row.original에 rfqId가 있다고 가정 + openCommentSheet( + rowAction.row.original.vendorId ?? 0, + rowAction.row.original.rfqId ?? 0, + ) + } else if (rowAction.type === "files") { + openFilesDialog( + rowAction.row.original.tbeId ?? 0, + rowAction.row.original.vendorId ?? 0, + rowAction.row.original.rfqId ?? 0, + ) + } + }, [rowAction]) + + // ----------------------------------------------------------- + // 댓글 시트 열기 + // ----------------------------------------------------------- + async function openCommentSheet(vendorId: number, rfqId: number) { + setInitialComments([]) + + const comments = rowAction?.row.original.comments + if (comments && comments.length > 0) { + const commentWithAttachments: TbeComment[] = await Promise.all( + comments.map(async (c) => { + const attachments = await fetchRfqAttachmentsbyCommentId(c.id) + return { + ...c, + commentedBy: 1, // DB나 API 응답에 있다고 가정 + attachments, + } + }) + ) + setInitialComments(commentWithAttachments) + } + + setSelectedVendorIdForComments(vendorId) + setSelectedRfqIdForComments(rfqId) + setCommentSheetOpen(true) + } + + // ----------------------------------------------------------- + // 파일 다이얼로그 열기 + // ----------------------------------------------------------- + const openFilesDialog = (tbeId: number, vendorId: number, rfqId: number) => { + setSelectedTbeIdForFiles(tbeId) + setSelectedVendorIdForFiles(vendorId) + setSelectedRfqIdForFiles(rfqId) + setIsFileDialogOpen(true) + } + + // ----------------------------------------------------------- + // 테이블 컬럼 + // ----------------------------------------------------------- + const columns = React.useMemo( + () => + getColumns({ + setRowAction, + router, + openCommentSheet, // 필요하면 직접 호출 가능 + openFilesDialog, + }), + [setRowAction, router] + ) + + // ----------------------------------------------------------- + // 필터 필드 + // ----------------------------------------------------------- + const filterFields: DataTableFilterField<VendorWithTbeFields>[] = [ + // 예: 표준 필터 + ] + const advancedFilterFields: DataTableAdvancedFilterField<VendorWithTbeFields>[] = [ + { id: "vendorName", label: "Vendor Name", type: "text" }, + { id: "vendorCode", label: "Vendor Code", type: "text" }, + { id: "email", label: "Email", type: "text" }, + { id: "country", label: "Country", type: "text" }, + { + id: "vendorStatus", + label: "Vendor Status", + type: "multi-select", + options: vendors.status.enumValues.map((status) => ({ + label: toSentenceCase(status), + value: status, + })), + }, + { id: "rfqVendorUpdated", label: "Updated at", type: "date" }, + ] + + // ----------------------------------------------------------- + // 테이블 생성 훅 + // ----------------------------------------------------------- + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "rfqVendorUpdated", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + <DataTable table={table}> + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <VendorsTableToolbarActions table={table} rfqId={selectedRfqIdForFiles ?? 0} /> + </DataTableAdvancedToolbar> + </DataTable> + + {/* 댓글 시트 */} + <CommentSheet + currentUserId={1} + open={commentSheetOpen} + onOpenChange={setCommentSheetOpen} + vendorId={selectedVendorIdForComments ?? 0} + rfqId={selectedRfqIdForComments ?? 0} // ← 여기! + initialComments={initialComments} + /> + + {/* 파일 업로드/다운로드 다이얼로그 */} + <TBEFileDialog + isOpen={isFileDialogOpen} + onOpenChange={setIsFileDialogOpen} + tbeId={selectedTbeIdForFiles ?? 0} + vendorId={selectedVendorIdForFiles ?? 0} + rfqId={selectedRfqIdForFiles ?? 0} // ← 여기! + onRefresh={handleRefresh} + /> + </> + ) +}
\ No newline at end of file diff --git a/lib/unstable-cache.ts b/lib/unstable-cache.ts new file mode 100644 index 00000000..c17bb9d8 --- /dev/null +++ b/lib/unstable-cache.ts @@ -0,0 +1,19 @@ +/** + * @see https://github.com/ethanniser/NextMaster/blob/main/src/lib/unstable-cache.ts + */ + +import { cache } from "react" +import { unstable_cache as next_unstable_cache } from "next/cache" + +// next_unstable_cache doesn't handle deduplication, so we wrap it in React's cache +export const unstable_cache = <Inputs extends unknown[], Output>( + cb: (...args: Inputs) => Promise<Output>, + keyParts: string[], + options?: { + /** + * The revalidation interval in seconds. + */ + revalidate?: number | false + tags?: string[] + } +) => cache(next_unstable_cache(cb, keyParts, options)) diff --git a/lib/users/repository.ts b/lib/users/repository.ts new file mode 100644 index 00000000..78d1668b --- /dev/null +++ b/lib/users/repository.ts @@ -0,0 +1,128 @@ +// lib/users/repository.ts +import db from '@/db/db'; +import { users, otps, type User, Role, roles, userRoles } from '@/db/schema/users'; +import { Otp } from '@/types/user'; +import { eq,and ,asc} from 'drizzle-orm'; + +// 모든 사용자 조회 +export const getAllUsers = async (): Promise<User[]> => { + const usersRes = await db.select().from(users).execute(); + return usersRes +}; + +export async function getRoleAssignedUsers(roleId: number) { + const rows = await db + .select() + .from(userRoles) + .where(eq(userRoles.roleId, roleId)) + return rows.map((r) => r.userId) // [1, 2, 5, ...] +} + +// ID로 사용자 조회 +export const getUserById = async (id: number): Promise<User | null> => { + const usersRes = await db.select().from(users).where(eq(users.id, id)).execute(); + if (usersRes.length === 0) return null; + + const user = usersRes[0]; + return user +}; + +// Email로 사용자 조회 +export const getUserByEmail = async (email: string): Promise<User | null> => { + const usersRes = await db.select().from(users).where(eq(users.email, email)).execute(); + if (usersRes.length === 0) return null; + + const user = usersRes[0]; + return user +}; + + +// 새 사용자 생성 +export const createUser = async (name: string, email: string): Promise<User> => { + const usersRes = await db.insert(users).values({ name, email }).returning(); + const user = usersRes[0]; + return user +}; + +// 사용자 업데이트 +export const updateUser = async (id: number, data: Partial<User>): Promise<User | null> => { + const usersRes = await db.update(users).set(data).where(eq(users.id, id)).returning(); + if (usersRes.length === 0) return null; + const user = usersRes[0]; + return user +}; + +// 사용자 삭제 +export const deleteUser = async (id: number): Promise<boolean> => { + const result = await db.delete(users).where(eq(users.id, id)).execute(); + return (result.rowCount ?? 0) > 0; // null일 경우 0으로 처리 +}; + + +// 새 otp 생성 +export const createOtp = async ( email: string, code:string, createdAt:Date, otpToken:string, otpExpires:Date ): Promise<Otp> => { + const otp = await db.insert(otps).values({ email, code, createdAt, otpToken,otpExpires }).returning(); + return otp[0] +}; + + +export const findOtpByEmail = async (email: string): Promise<Otp | null> => { + const [otpRecord] = await db + .select() + .from(otps) + .where(eq(otps.email, email)) + + return otpRecord ?? null +} + +export const updateOtp = async ( + email: string, + code: string, + createdAt: Date, + otpToken: string, + otpExpires: Date +): Promise<Otp> => { + const rows = await db + .update(otps) + .set({ + code, + createdAt, + otpToken, + otpExpires, + }) + .where(eq(otps.email, email)) + .returning(); + + return rows[0]; +}; + +// Email 및 토큰으로 opt 조회 +export const getOtpByEmailAndToken = async (email: string, token:string): Promise<Otp | null> => { + const opts = await db.select().from(otps).where(eq(otps.email, email)).execute(); + if (opts.length === 0) return null; + + const otp = opts[0]; + return otp +}; + + +export const getOtpByEmailAndCode = async ( + email: string, + code: string +): Promise<Otp | null> => { + + console.log(email, code, "db") + + const [otp] = await db + .select() + .from(otps) + .where( + and(eq(otps.email, email), eq(otps.code, code)) + ); + + return otp ?? null; +}; + +export async function findAllRoles(): Promise<Role[]> { + return db.select().from(roles).where(eq(roles.domain ,'evcp')).orderBy(asc(roles.name)); +}
\ No newline at end of file diff --git a/lib/users/send-otp.ts b/lib/users/send-otp.ts new file mode 100644 index 00000000..c8cfb83d --- /dev/null +++ b/lib/users/send-otp.ts @@ -0,0 +1,71 @@ +"use server"; + +import { headers } from 'next/headers'; +import { sendEmail } from '@/lib/mail/sendEmail'; +import jwt from 'jsonwebtoken'; +import { findUserByEmail, addNewOtp } from '@/lib/users/service'; + + +export async function sendOtpAction(email: string, lng: string) { + // Next.js의 headers() API로 헤더 정보를 얻을 수 있습니다. + const headersList = await headers(); + + // 호스트 정보 (request.nextUrl.host 대체) + const host = headersList.get('host') || 'localhost:3000'; + + // 사용자 조회 + const user = await findUserByEmail(email); + + if (!user) { + // 서버 액션에서 에러 던지면, 클라이언트 컴포넌트에서 try-catch로 잡을 수 있습니다. + throw new Error('User does not exist'); + } + + // OTP 및 만료 시간 생성 + const otp = Math.floor(100000 + Math.random() * 900000).toString(); + const expires = new Date(Date.now() + 10 * 60 * 1000); // 10분 후 만료 + const token = jwt.sign( + { + email, + otp, + exp: Math.floor(expires.getTime() / 1000), + }, + process.env.JWT_SECRET! + ); + + // DB에 OTP 추가 + await addNewOtp(email, otp, new Date(), token, expires); + + // 이메일에서 사용할 URL 구성 + const verificationUrl = `http://${host}/ko/login?token=${token}`; + + // IP 정보로부터 지역 조회 (ip-api 사용) + const ip = headersList.get('x-forwarded-for')?.split(',')[0]?.trim() || ''; + let location = ''; + try { + const response = await fetch(`http://ip-api.com/json/${ip}?fields=country,city`); + const data = await response.json(); + location = data.city && data.country ? `${data.city}, ${data.country}` : ''; + } catch (error) { + // 위치 조회 실패 시 무시 + } + + // OTP 이메일 발송 + await sendEmail({ + to: email, + subject: `${otp} - SHI eVCP Sign-in Verification`, + template: 'otp', + context: { + name: user.name, + otp, + verificationUrl, + location, + language: lng, + }, + }); + + // 클라이언트로 반환할 수 있는 값 + return { + success: true, + }; +}
\ No newline at end of file diff --git a/lib/users/service.ts b/lib/users/service.ts new file mode 100644 index 00000000..ae97beed --- /dev/null +++ b/lib/users/service.ts @@ -0,0 +1,413 @@ +// lib/users/service.ts +"use server"; + +import { Otp } from '@/types/user'; +import { getAllUsers, createUser, getUserById, updateUser, deleteUser, getUserByEmail, createOtp,getOtpByEmailAndToken, updateOtp, findOtpByEmail ,getOtpByEmailAndCode, findAllRoles, getRoleAssignedUsers} from './repository'; +import logger from '@/lib/logger'; +import { Role, userRoles, users, userView, type User } from '@/db/schema/users'; +import { saveDocument } from '../storage'; +import { GetUsersSchema } from '../admin-users/validations'; +import { revalidateTag, unstable_cache, unstable_noStore } from 'next/cache'; +import { filterColumns } from '../filter-columns'; +import { asc, desc, ilike, inArray, and, gte, lte, not, or, eq } from "drizzle-orm"; +import { countUsers, selectUsersWithCompanyAndRoles } from '../admin-users/repository'; +import db from "@/db/db"; +import { getErrorMessage } from "@/lib/handle-error"; + +interface AssignUsersArgs { + roleId: number + userIds: number[] +} + + +export const fetchAllUsers = async (): Promise<User[]> => { + try { + logger.info('Fetching all users'); + const users = await getAllUsers(); + logger.debug({ count: users.length }, 'Fetched users successfully'); + return users; + } catch (error) { + logger.error({ error }, 'Error fetching all users'); + throw new Error('Failed to fetch users'); + } +}; + + +export const fetchRoleAssignedUserID = async (roleId: number) => { + try { + logger.info('Fetching all users'); + const users = await getRoleAssignedUsers(roleId); + logger.debug({ count: users.length }, 'Fetched users successfully'); + return users; + } catch (error) { + logger.error({ error }, 'Error fetching all users'); + throw new Error('Failed to fetch users'); + } +}; + + +export const addNewUser = async (name: string, email: string): Promise<User> => { + try { + logger.info({ name, email }, 'Creating a new user'); + const user = await createUser(name, email); + logger.debug({ user }, 'User created successfully'); + return user; + } catch (error) { + logger.error({ error }, 'Error creating a new user'); + throw new Error('Failed to create user'); + } +}; + +export const findUserById = async (id: number): Promise<User | null> => { + try { + logger.info({ id }, 'Fetching user by ID'); + const user = await getUserById(id); + if (!user) { + logger.warn({ id }, 'User not found'); + } else { + logger.debug({ user }, 'User fetched successfully'); + } + return user; + } catch (error) { + logger.error({ error }, 'Error fetching user by ID'); + throw new Error('Failed to fetch user'); + } +}; + +export const findUserByEmail = async (email: string): Promise<User | null> => { + try { + logger.info({ email }, 'Fetching user by Email'); + const user = await getUserByEmail(email); + if (!user) { + logger.warn({ email }, 'User not found'); + } else { + logger.debug({ user }, 'User fetched successfully'); + } + return user; + } catch (error) { + logger.error({ error }, 'Error fetching user by ID'); + throw new Error('Failed to fetch user'); + } +}; + +export const modifyUser = async (id: number, data: Partial<User>): Promise<User | null> => { + try { + logger.info({ id, data }, 'Updating user'); + const user = await updateUser(id, data); + if (!user) { + logger.warn({ id }, 'User not found for update'); + } else { + logger.debug({ user }, 'User updated successfully'); + } + return user; + } catch (error) { + logger.error({ error }, 'Error updating user'); + throw new Error('Failed to update user'); + } +}; + +export const removeUser = async (id: number): Promise<boolean> => { + try { + logger.info({ id }, 'Deleting user'); + const success = await deleteUser(id); + if (success) { + logger.debug({ id }, 'User deleted successfully'); + } else { + logger.warn({ id }, 'User not found for deletion'); + } + return success; + } catch (error) { + logger.error({ error }, 'Error deleting user'); + throw new Error('Failed to delete user'); + } +}; + +export const addNewOtp = async ( + email: string, + code: string, + createdAt: Date, + otpToken: string, + otpExpires: Date +): Promise<Otp> => { + try { + logger.info({ email }, 'Creating or updating an OTP record'); + + // 1) 먼저 email로 Otp가 있는지 조회 + const existingOtp = await findOtpByEmail(email); + + // 2) 이미 있으면 update + if (existingOtp) { + const otp = await updateOtp(email, code, createdAt, otpToken, otpExpires); + logger.debug({ otp }, 'OTP updated successfully'); + return otp; + } + // 3) 없으면 새로 생성 + else { + const otp = await createOtp(email, code, createdAt, otpToken, otpExpires); + logger.debug({ otp }, 'OTP created successfully'); + return otp; + } + } catch (error) { + logger.error({ error }, 'Error creating or updating OTP'); + throw new Error('Failed to create or update OTP'); + } +}; + +export const findOtpByEmailandToken = async (email: string, otpToken: string): Promise<Otp | null> => { + try { + logger.info({ email }, 'Fetching otp by Email'); + const otp = await getOtpByEmailAndToken(email, otpToken); + if (!otp) { + logger.warn({ email }, 'Otp not found'); + } else { + logger.debug({ otp }, 'Otp fetched successfully'); + } + return otp; + } catch (error) { + logger.error({ error }, 'Error fetching user by ID'); + throw new Error('Failed to fetch user'); + } +}; + + +export async function findEmailandOtp(email: string, code: string) { + try { + // 1) otp 조회 + const otpRecord: Otp | null = await getOtpByEmailAndCode(email, code) + if (!otpRecord) { + return null + } + + // 2) 사용자 정보 추가로 조회 + const userRecord: User | null = await getUserByEmail(email) + if (!userRecord) { + return null + } + + // 3) 필요한 형태로 "통합된 객체"를 반환 + return { + otpExpires: otpRecord.otpExpires, + email: userRecord.email, + name: userRecord.name, // DB 에서 가져온 실제 이름 + id: userRecord.id, // user id + imageUrl:userRecord.imageUrl, + companyId:userRecord.companyId, + domain:userRecord.domain + // 기타 필요한 필드... + } + + } catch (error) { + // 에러 처리 + throw new Error('Failed to fetch user & otp') + } +} + +export async function updateUserProfileImage(formData: FormData) { + // 1) FormData에서 데이터 꺼내기 + const file = formData.get("file") as File | null + const userId = Number(formData.get("userId")) + const name = formData.get("name") as string + const email = formData.get("email") as string + + // 2) 기본적인 유효성 검증 + if (!file) { + throw new Error("No file found in the FormData.") + } + if (!userId) { + throw new Error("userId is required.") + } + + try { + // 3) 파일 저장 (해시 생성) + const directory = './public/profiles' + const { hashedFileName } = await saveDocument(file, directory) + + // 4) DB 업데이트 + const imageUrl = hashedFileName + const data = { name, email, imageUrl } + const user = await updateUser(userId, data) + if (!user) { + // updateUser가 null을 반환하면, DB 업데이트 실패 혹은 해당 유저가 없음 + throw new Error(`User with id=${userId} not found or update failed.`) + } + + // 5) 성공 시 성공 정보 반환 + return { success: true, user } + } catch (err: any) { + // DB 업데이트 중 발생하는 에러나 saveDocument 내부 에러 등을 처리 + console.error("[updateUserProfileImage] Error:", err) + throw new Error(err.message ?? "Failed to update user profile.") + } +} + +export async function getUsersEVCP(input: GetUsersSchema) { + return unstable_cache( + async () => { + try { + const offset = (input.page - 1) * input.perPage; + + // (1) advancedWhere + const advancedWhere = filterColumns({ + table: userView, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + // (2) globalWhere + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + ilike(userView.user_name, s), + ilike(userView.user_email, s), + ilike(userView.company_name, s) + ); + } + + // (3) 디폴트 domainWhere = eq(userView.domain, "partners") + // 다만, 사용자가 이미 domain 필터를 줬다면 적용 X + let domainWhere; + const hasDomainFilter = input.filters?.some((f) => f.id === "user_domain"); + if (!hasDomainFilter) { + domainWhere = eq(userView.user_domain, "evcp"); + } + + // (4) 최종 where + const finalWhere = and(advancedWhere, globalWhere, domainWhere); + + // (5) 정렬 + const orderBy = + input.sort.length > 0 + ? input.sort.map((item) => + item.desc ? desc(userView[item.id]) : asc(userView[item.id]) + ) + : [desc(users.createdAt)]; + + // ... + const { data, total } = await db.transaction(async (tx) => { + const data = await selectUsersWithCompanyAndRoles(tx, { + where: finalWhere, + orderBy, + offset, + limit: input.perPage, + }); + + const total = await countUsers(tx, finalWhere); + return { data, total }; + }); + + const pageCount = Math.ceil(total / input.perPage); + return { data, pageCount }; + } catch (err) { + return { data: [], pageCount: 0 }; + } + }, + [JSON.stringify(input)], + { + revalidate: 3600, + tags: ["users"], + } + )(); +} + +export async function getAllRoles(): Promise<Role[]> { + try { + return await findAllRoles(); + } catch (err) { + throw new Error("Failed to get roles"); + } +} + + +export async function getUsersAll(input: GetUsersSchema, domain: string) { + return unstable_cache( + async () => { + try { + const offset = (input.page - 1) * input.perPage; + + // (1) advancedWhere + const advancedWhere = filterColumns({ + table: userView, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + // (2) globalWhere + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + ilike(userView.user_name, s), + ilike(userView.user_email, s), + ilike(userView.company_name, s) + ); + } + + // (3) domainWhere - 무조건 들어가야 하는 domain 조건 + const domainWhere = eq(userView.user_domain, domain); + + // (4) 최종 where + // domainWhere과 advancedWhere, globalWhere를 모두 and로 묶는다. + // (globalWhere가 존재하지 않을 수 있으니, and() 호출 시 undefined를 자동 무시할 수도 있음) + const finalWhere = and(domainWhere, advancedWhere, globalWhere); + + // (5) 정렬 + const orderBy = + input.sort.length > 0 + ? input.sort.map((item) => + item.desc ? desc(userView[item.id]) : asc(userView[item.id]) + ) + : [desc(users.createdAt)]; + + const { data, total } = await db.transaction(async (tx) => { + const data = await selectUsersWithCompanyAndRoles(tx, { + where: finalWhere, + orderBy, + offset, + limit: input.perPage, + }); + + const total = await countUsers(tx, finalWhere); + return { data, total }; + }); + + const pageCount = Math.ceil(total / input.perPage); + return { data, pageCount }; + } catch (err) { + return { data: [], pageCount: 0 }; + } + }, + // (6) 캐시 종속성 배열에 domain도 추가 + [JSON.stringify(input), domain], + { + revalidate: 3600, + tags: ["users"], + } + )(); +} + + +export async function assignUsersToRole(roleId: number, userIds: number[]) { + unstable_noStore(); // 캐싱 방지(Next.js 서버 액션용) + try{ + await db.transaction(async (tx) => { + // 1) 기존 userRoles 레코드 삭제 + await tx.delete(userRoles).where(eq(userRoles.roleId, roleId)) + + // 2) 새로 넣기 + if (userIds.length > 0) { + await tx.insert(userRoles).values( + userIds.map((uid) => ({ userId: uid, roleId })) + ) + } + }) + revalidateTag("users"); + revalidateTag("roles"); + + return { data: null, error: null }; + } catch (err){ + return { data: null, error: getErrorMessage(err) }; + + } + +} diff --git a/lib/users/table/assign-roles-dialog.tsx b/lib/users/table/assign-roles-dialog.tsx new file mode 100644 index 00000000..003f6500 --- /dev/null +++ b/lib/users/table/assign-roles-dialog.tsx @@ -0,0 +1,194 @@ +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Check, ChevronsUpDown, Loader, UserRoundPlus } from "lucide-react" +import { cn } from "@/lib/utils" +import { toast } from "sonner" + +import { Textarea } from "@/components/ui/textarea" +import { Company } from "@/db/schema/companies" +import { getAllCompanies } from "@/lib/admin-users/service" +import { + Popover, + PopoverTrigger, + PopoverContent, +} from "@/components/ui/popover" +import { + Command, + CommandInput, + CommandList, + CommandGroup, + CommandItem, + CommandEmpty, +} from "@/components/ui/command" +import { assignRolesToUsers, getAllRoleView } from "@/lib/roles/services" +import { RoleView } from "@/db/schema/users" +import { type UserView } from "@/db/schema/users" +import { type Row } from "@tanstack/react-table" +import { createRoleAssignmentSchema, CreateRoleAssignmentSchema, createRoleSchema, CreateRoleSchema } from "@/lib/roles/validations" +import { MultiSelect } from "@/components/ui/multi-select" + +interface AssignRoleDialogProps + extends React.ComponentPropsWithoutRef<typeof Dialog> { + users: Row<UserView>["original"][] + +} + + +export function AssignRoleDialog({ users }: AssignRoleDialogProps) { + const [open, setOpen] = React.useState(false) + const [isAddPending, startAddTransition] = React.useTransition() + const [roles, setRoles] = React.useState<RoleView[]>([]) // 회사 목록 + const [loading, setLoading] = React.useState(false) + + const partnersRoles = roles.filter(v => v.domain === "partners") + const evcpRoles = roles.filter(v => v.domain === "evcp") + + + React.useEffect(() => { + getAllRoleView("evcp").then((res) => { + setRoles(res) + }) + }, []) + + + const form = useForm<CreateRoleAssignmentSchema>({ + resolver: zodResolver(createRoleAssignmentSchema), + defaultValues: { + evcpRoles: [], + }, + }) + + + function handleDialogOpenChange(nextOpen: boolean) { + if (!nextOpen) { + form.reset() + } + setOpen(nextOpen) + } + + const evcpUsers = users.filter(v => v.user_domain === "evcp"); + + + async function onSubmit(data: CreateRoleAssignmentSchema) { + console.log(data.evcpRoles.map((v)=>Number(v))) + startAddTransition(async () => { + + + // if(partnerUsers.length>0){ + // const result = await assignRolesToUsers( partnerUsers.map(v=>v.user_id) ,data.partnersRoles) + + // if (result.error) { + // toast.error(`에러: ${result.error}`) + // return + // } + // } + + if (evcpUsers.length > 0) { + const result = await assignRolesToUsers( data.evcpRoles.map((v)=>Number(v)), evcpUsers.map(v => v.user_id)) + + if (result.error) { + toast.error(`에러: ${result.error}`) + return + } + } + + form.reset() + setOpen(false) + toast.success("Role assgined") + }) + } + + return ( + <Dialog open={open} onOpenChange={handleDialogOpenChange}> + <DialogTrigger asChild> + <Button variant="default" size="sm"> + <UserRoundPlus className="mr-2 size-4" aria-hidden="true" /> + Assign Role ({users.length}) + </Button> + </DialogTrigger> + + <DialogContent> + <DialogHeader> + <DialogTitle>Assign Roles to {evcpUsers.length} Users</DialogTitle> + <DialogDescription> + Role을 Multi-select 하시기 바랍니다. + </DialogDescription> + </DialogHeader> + + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)}> + <div className="space-y-4 py-4"> + {/* evcp 롤 선택 */} + {evcpUsers.length > 0 && + <FormField + control={form.control} + name="evcpRoles" + render={({ field }) => ( + <FormItem> + <FormLabel>eVCP Role</FormLabel> + <FormControl> + <MultiSelect + options={evcpRoles.map((role) => ({ value: String(role.id), label: role.name }))} + onValueChange={(values) => { + field.onChange(values); + }} + + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + } + </div> + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => setOpen(false)} + disabled={isAddPending} + > + Cancel + </Button> + <Button + type="submit" + disabled={form.formState.isSubmitting || isAddPending} + > + {isAddPending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + Assgin + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/users/table/users-table-columns.tsx b/lib/users/table/users-table-columns.tsx new file mode 100644 index 00000000..c0eb9520 --- /dev/null +++ b/lib/users/table/users-table-columns.tsx @@ -0,0 +1,154 @@ +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { Ellipsis } from "lucide-react" +import { userRoles, type UserView } from "@/db/schema/users" + +import { formatDate } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header" +import { UserWithCompanyAndRoles } from "@/types/user" +import { getErrorMessage } from "@/lib/handle-error" + +import { modifiUser } from "@/lib/admin-users/service" +import { toast } from "sonner" + +import { euserColumnsConfig } from "@/config/euserColumnsConfig" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { MultiSelect } from "@/components/ui/multi-select" + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<UserView> | null>> +} + +/** + * tanstack table 컬럼 정의 (중첩 헤더 버전) + */ +export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<UserView>[] { + // ---------------------------------------------------------------- + // 1) select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef<UserView> = { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + size:40, + enableSorting: false, + enableHiding: false, + } + + + + const groupMap: Record<string, ColumnDef<UserView>[]> = {} + + euserColumnsConfig.forEach((cfg) => { + // 만약 group가 없으면 "_noGroup" 처리 + const groupName = cfg.group || "_noGroup" + + if (!groupMap[groupName]) { + groupMap[groupName] = [] + } + + // child column 정의 + const childCol: ColumnDef<UserView> = { + accessorKey: cfg.id, + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title={cfg.label} /> + ), + meta: { + excelHeader: cfg.excelHeader, + group: cfg.group, + type: cfg.type, + }, + cell: ({ row, cell }) => { + + if (cfg.id === "created_at") { + const dateVal = cell.getValue() as Date + return formatDate(dateVal) + } + + if (cfg.id === "roles") { + const roleValues = row.original.roles; + return ( + <div className="flex flex-wrap gap-1"> + {roleValues.map((v) => ( + v === null?"": + <Badge key={v} variant="outline"> + {v} + </Badge> + ))} + </div> + ); + } + + return row.getValue(cfg.id) ?? "" + }, + } + + groupMap[groupName].push(childCol) + }) + + // ---------------------------------------------------------------- + // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기 + // ---------------------------------------------------------------- + const nestedColumns: ColumnDef<UserView>[] = [] + + // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함 + // 여기서는 그냥 Object.entries 순서 + Object.entries(groupMap).forEach(([groupName, colDefs]) => { + if (groupName === "_noGroup") { + // 그룹 없음 → 그냥 최상위 레벨 컬럼 + nestedColumns.push(...colDefs) + } else { + // 상위 컬럼 + nestedColumns.push({ + id: groupName, + header: groupName, // "Basic Info", "Metadata" 등 + columns: colDefs, + }) + } + }) + + // ---------------------------------------------------------------- + // 4) 최종 컬럼 배열: select, nestedColumns, actions + // ---------------------------------------------------------------- + return [ + selectColumn, + ...nestedColumns, + ] +}
\ No newline at end of file diff --git a/lib/users/table/users-table-toolbar-actions.tsx b/lib/users/table/users-table-toolbar-actions.tsx new file mode 100644 index 00000000..106953a6 --- /dev/null +++ b/lib/users/table/users-table-toolbar-actions.tsx @@ -0,0 +1,61 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Download, Upload } from "lucide-react" +import { toast } from "sonner" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" + + + +import { UserView } from "@/db/schema/users" +import { DeleteUsersDialog } from "@/lib/admin-users/table/delete-ausers-dialog" +import { AssignRoleDialog } from "./assign-roles-dialog" + +interface UsersTableToolbarActionsProps { + table: Table<UserView> +} + +export function UsersTableToolbarActions({ table }: UsersTableToolbarActionsProps) { + // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식 + const fileInputRef = React.useRef<HTMLInputElement>(null) + + + function handleImportClick() { + // 숨겨진 <input type="file" /> 요소를 클릭 + fileInputRef.current?.click() + } + + return ( + <div className="flex items-center gap-2"> + + {table.getFilteredSelectedRowModel().rows.length > 0 ? ( + <AssignRoleDialog + users={table + .getFilteredSelectedRowModel() + .rows.map((row) => row.original)} + /> + ) : null} + + + + {/** 4) Export 버튼 */} + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(table, { + filename: "roles", + excludeColumns: ["select", "actions"], + }) + } + className="gap-2" + > + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Export</span> + </Button> + </div> + ) +}
\ No newline at end of file diff --git a/lib/users/table/users-table.tsx b/lib/users/table/users-table.tsx new file mode 100644 index 00000000..53cb961e --- /dev/null +++ b/lib/users/table/users-table.tsx @@ -0,0 +1,150 @@ +"use client" + +import * as React from "react" +import { userRoles , type UserView} from "@/db/schema/users" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" + +import { toSentenceCase } from "@/lib/utils" +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import { DataTableToolbar } from "@/components/data-table/data-table-toolbar" + +import type { + getAllRoles, getUsersEVCP +} from "@/lib//users/service" +import { getColumns } from "./users-table-columns" +import { UsersTableToolbarActions } from "./users-table-toolbar-actions" + + + +interface UsersTableProps { + promises: Promise< + [ + Awaited<ReturnType<typeof getUsersEVCP>>, + Record<number, number>, + Awaited<ReturnType<typeof getAllRoles>> + ] + > +} +type RoleCounts = Record<string, number> + +export function UserTable({ promises }: UsersTableProps) { + + const [{ data, pageCount }, roleCountsRaw, roles] = + React.use(promises) + + + const [rowAction, setRowAction] = + React.useState<DataTableRowAction<UserView> | null>(null) + + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + const roleCounts = roleCountsRaw as RoleCounts + + + /** + * This component can render either a faceted filter or a search filter based on the `options` prop. + * + * @prop options - An array of objects, each representing a filter option. If provided, a faceted filter is rendered. If not, a search filter is rendered. + * + * Each `option` object has the following properties: + * @prop {string} label - The label for the filter option. + * @prop {string} value - The value for the filter option. + * @prop {React.ReactNode} [icon] - An optional icon to display next to the label. + * @prop {boolean} [withCount] - An optional boolean to display the count of the filter option. + */ + const filterFields: DataTableFilterField<UserView>[] = [ + { + id: "user_email", + label: "Email", + placeholder: "Filter email...", + }, + + ] + + /** + * Advanced filter fields for the data table. + * These fields provide more complex filtering options compared to the regular filterFields. + * + * Key differences from regular filterFields: + * 1. More field types: Includes 'text', 'multi-select', 'date', and 'boolean'. + * 2. Enhanced flexibility: Allows for more precise and varied filtering options. + * 3. Used with DataTableAdvancedToolbar: Enables a more sophisticated filtering UI. + * 4. Date and boolean types: Adds support for filtering by date ranges and boolean values. + */ + const advancedFilterFields: DataTableAdvancedFilterField<UserView>[] = [ + { + id: "user_name", + label: "User Name", + type: "text", + }, + { + id: "user_email", + label: "Email", + type: "text", + }, + + { + id: "roles", + label: "Roles", + type: "multi-select", + options: roles.map((role) => { + return { + label: toSentenceCase(role.name), + value: role.id, + count: roleCounts[role.id], // 이 값이 undefined인지 확인 + }; + }), + }, + { + id: "created_at", + label: "Created at", + type: "date", + }, + ] + + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "created_at", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => `${originalRow.user_id}`, + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + <DataTable + table={table} + + > + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <UsersTableToolbarActions table={table}/> + </DataTableAdvancedToolbar> + + </DataTable> + + + </> + ) +} diff --git a/lib/users/verifyOtp.ts b/lib/users/verifyOtp.ts new file mode 100644 index 00000000..5de76f90 --- /dev/null +++ b/lib/users/verifyOtp.ts @@ -0,0 +1,28 @@ +// lib/users/verifyOtp.ts +import { findEmailandOtp } from '@/lib/users/service' + +// "email과 code가 맞으면 유저 정보, 아니면 null" 형태로 작성 +export async function verifyOtp(email: string, code: string) { + // DB에서 email과 code가 맞는지, 만료 안됐는지 검증 + const otpRecord = await findEmailandOtp(email, code) + if (!otpRecord) { + return null + } + + // 만료 체크 + if (otpRecord.otpExpires && otpRecord.otpExpires < new Date()) { + return null + } + + // 여기서 otpRecord에 유저 정보가 있다고 가정 + // 예: otpRecord.userId, otpRecord.userName, otpRecord.email 등 + // 실제 DB 설계에 맞춰 필드명을 조정하세요. + return { + email: otpRecord.email, + name: otpRecord.name, + id: otpRecord.id, + imageUrl: otpRecord.imageUrl, + companyId: otpRecord.companyId, + domain: otpRecord.domain, + } +}
\ No newline at end of file diff --git a/lib/users/verifyToken.ts b/lib/users/verifyToken.ts new file mode 100644 index 00000000..745a1052 --- /dev/null +++ b/lib/users/verifyToken.ts @@ -0,0 +1,38 @@ +"use server"; + +import jwt from 'jsonwebtoken'; +import { findOtpByEmailandToken } from '@/lib/users/service'; + +export async function verifyTokenAction(token: string) { + if (!token) { + // 토큰이 없으면 바로 false 반환 + return { valid: false }; + } + + try { + // 토큰 검증 + const decoded = jwt.verify(token, process.env.JWT_SECRET!) as { email: string; otp: string }; + const { email } = decoded; + + // DB에서 OTP 정보 조회 + const otp = await findOtpByEmailandToken(email, token); + if (!otp) { + // 해당하는 OTP/토큰이 없으면 invalid + return { valid: false }; + } + + // 토큰 동일성 및 만료 확인 + if (otp.otpToken !== token || (otp.otpExpires && otp.otpExpires < new Date())) { + return { valid: false }; + } + + // 여기까지 통과하면 valid + return { + valid: true, + email, + }; + } catch (error) { + // JWT 검증 실패 + return { valid: false }; + } +}
\ No newline at end of file diff --git a/lib/utils.ts b/lib/utils.ts new file mode 100644 index 00000000..2eca9285 --- /dev/null +++ b/lib/utils.ts @@ -0,0 +1,75 @@ +import { clsx, type ClassValue } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} + +export function formatDate( + date: Date | string | number, + locale: string = "en-US", + opts: Intl.DateTimeFormatOptions = {}, + includeTime: boolean = false +) { + return new Intl.DateTimeFormat(locale, { + month: opts.month ?? "long", + day: opts.day ?? "numeric", + year: opts.year ?? "numeric", + // Add time options when includeTime is true + ...(includeTime && { + hour: opts.hour ?? "2-digit", + minute: opts.minute ?? "2-digit", + second: opts.second ?? "2-digit", + hour12: opts.hour12 ?? false, // Use 24-hour format by default + }), + ...opts, // This allows overriding any of the above defaults + }).format(new Date(date)) +} + +// Alternative: Create a separate function for date and time +export function formatDateTime( + date: Date | string | number, + locale: string = "en-US", + opts: Intl.DateTimeFormatOptions = {} +) { + return new Intl.DateTimeFormat(locale, { + month: opts.month ?? "long", + day: opts.day ?? "numeric", + year: opts.year ?? "numeric", + hour: opts.hour ?? "2-digit", + minute: opts.minute ?? "2-digit", + second: opts.second ?? "2-digit", + hour12: opts.hour12 ?? false, + ...opts, + }).format(new Date(date)) +} + +export function toSentenceCase(str: string) { + return str + .replace(/_/g, " ") + .replace(/([A-Z])/g, " $1") + .toLowerCase() + .replace(/^\w/, (c) => c.toUpperCase()) + .replace(/\s+/g, " ") + .trim() +} + +/** + * @see https://github.com/radix-ui/primitives/blob/main/packages/core/primitive/src/primitive.tsx + */ +export function composeEventHandlers<E>( + originalEventHandler?: (event: E) => void, + ourEventHandler?: (event: E) => void, + { checkForDefaultPrevented = true } = {} +) { + return function handleEvent(event: E) { + originalEventHandler?.(event) + + if ( + checkForDefaultPrevented === false || + !(event as unknown as Event).defaultPrevented + ) { + return ourEventHandler?.(event) + } + } +} diff --git a/lib/vendor-data/services.ts b/lib/vendor-data/services.ts new file mode 100644 index 00000000..7f0c47c1 --- /dev/null +++ b/lib/vendor-data/services.ts @@ -0,0 +1,99 @@ +"use server"; + +import db from "@/db/db" +import { items } from "@/db/schema/items" +import { projects } from "@/db/schema/projects" +import { Tag, tags } from "@/db/schema/vendorData" +import { eq } from "drizzle-orm" +import { revalidateTag, unstable_noStore } from "next/cache"; +import { unstable_cache } from "@/lib/unstable-cache"; +import { contractItems, contracts } from "@/db/schema/contract"; + +// 스키마 import + +export interface ProjectWithContracts { + projectId: number + projectCode: string + projectName: string + projectType: string + + contracts: { + contractId: number + contractNo: string + contractName: string + // contractName 등 필요한 필드 추가 + packages: { + itemId: number + itemName: string + }[] + }[] +} + + +export async function getVendorProjectsAndContracts( + vendorId: number +): Promise<ProjectWithContracts[]> { + const rows = await db + .select({ + projectId: projects.id, + projectCode: projects.code, + projectName: projects.name, + projectType: projects.type, + + contractId: contracts.id, + contractNo: contracts.contractNo, + contractName: contracts.contractName, + + itemId: contractItems.id, + itemName: items.itemName, + }) + .from(contracts) + .innerJoin(projects, eq(contracts.projectId, projects.id)) + .innerJoin(contractItems, eq(contractItems.contractId, contracts.id)) + .innerJoin(items, eq(contractItems.itemId, items.id)) + .where(eq(contracts.vendorId, vendorId)) + + const projectMap = new Map<number, ProjectWithContracts>() + + for (const row of rows) { + // 1) 프로젝트 그룹 찾기 + let projectEntry = projectMap.get(row.projectId) + if (!projectEntry) { + // 새 프로젝트 항목 생성 + projectEntry = { + projectId: row.projectId, + projectCode: row.projectCode, + projectName: row.projectName, + projectType: row.projectType, + contracts: [], + } + projectMap.set(row.projectId, projectEntry) + } + + // 2) 프로젝트 안에서 계약(contractId) 찾기 + let contractEntry = projectEntry.contracts.find( + (c) => c.contractId === row.contractId + ) + if (!contractEntry) { + // 새 계약 항목 + contractEntry = { + contractId: row.contractId, + contractNo: row.contractNo, + contractName: row.contractName, + packages: [], + } + projectEntry.contracts.push(contractEntry) + } + + // 3) 계약의 packages 배열에 아이템 추가 + contractEntry.packages.push({ + itemId: row.itemId, + itemName: row.itemName, + }) + } + + return Array.from(projectMap.values()) +} + + +// 1) 태그 조회 diff --git a/lib/vendor-document-list/repository.ts b/lib/vendor-document-list/repository.ts new file mode 100644 index 00000000..43adf7ca --- /dev/null +++ b/lib/vendor-document-list/repository.ts @@ -0,0 +1,44 @@ +import db from "@/db/db"; +import { documentStagesView } from "@/db/schema/vendorDocu"; +import { + eq, + inArray, + not, + asc, + desc, + and, + ilike, + gte, + lte, + count, + gt, +} from "drizzle-orm"; +import { PgTransaction } from "drizzle-orm/pg-core"; + +export async function selectVendorDocuments( + tx: PgTransaction<any, any, any>, + params: { + where?: any; // drizzle-orm의 조건식 (and, eq...) 등 + orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[]; + offset?: number; + limit?: number; + } +) { + const { where, orderBy, offset = 0, limit = 10 } = params; + + return tx + .select() + .from(documentStagesView) + .where(where) + .orderBy(...(orderBy ?? [])) + .offset(offset) + .limit(limit); +} +/** 총 개수 count */ +export async function countVendorDocuments( + tx: PgTransaction<any, any, any>, + where?: any +) { + const res = await tx.select({ count: count() }).from(documentStagesView).where(where); + return res[0]?.count ?? 0; +} diff --git a/lib/vendor-document-list/service.ts b/lib/vendor-document-list/service.ts new file mode 100644 index 00000000..75c9b6cd --- /dev/null +++ b/lib/vendor-document-list/service.ts @@ -0,0 +1,284 @@ +"use server" + +import { eq, SQL } from "drizzle-orm" +import db from "@/db/db" +import { documents, documentStagesView, issueStages } from "@/db/schema/vendorDocu" +import { contracts } from "@/db/schema/vendorData" +import { GetVendorDcoumentsSchema } from "./validations" +import { unstable_cache } from "@/lib/unstable-cache"; +import { filterColumns } from "@/lib/filter-columns"; +import { getErrorMessage } from "@/lib/handle-error"; +import { asc, desc, ilike, inArray, and, gte, lte, not, or } from "drizzle-orm"; +import { countVendorDocuments, selectVendorDocuments } from "./repository" +import path from "path"; +import fs from "fs/promises"; +import { v4 as uuidv4 } from "uuid" +import { z } from "zod" +import { revalidateTag, unstable_noStore ,revalidatePath} from "next/cache"; + +/** + * 특정 vendorId에 속한 문서 목록 조회 + */ +export async function getVendorDocuments(input: GetVendorDcoumentsSchema, id: number) { + + return unstable_cache( + async () => { + try { + const offset = (input.page - 1) * input.perPage; + + // advancedTable 모드면 filterColumns()로 where 절 구성 + const advancedWhere = filterColumns({ + table: documentStagesView, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + let globalWhere + if (input.search) { + const s = `%${input.search}%` + globalWhere = or(ilike(documentStagesView.title, s), ilike(documentStagesView.docNumber, s) + ) + // 필요시 여러 칼럼 OR조건 (status, priority, etc) + } + + const finalWhere = and(advancedWhere, globalWhere, eq(documentStagesView.contractId, id)); + const orderBy = + input.sort.length > 0 + ? input.sort.map((item) => + item.desc ? desc(documentStagesView[item.id]) : asc(documentStagesView[item.id]) + ) + : [asc(documentStagesView.createdAt)]; + + // 트랜잭션 내부에서 Repository 호출 + const { data, total } = await db.transaction(async (tx) => { + const data = await selectVendorDocuments(tx, { + where: finalWhere, + orderBy, + offset, + limit: input.perPage, + }); + const total = await countVendorDocuments(tx, finalWhere); + return { data, total }; + }); + + const pageCount = Math.ceil(total / input.perPage); + + + return { data, pageCount }; + } catch (err) { + // 에러 발생 시 디폴트 + return { data: [], pageCount: 0 }; + } + }, + [JSON.stringify(input), String(id)], // 캐싱 키 + { + revalidate: 3600, + tags: [`vendor-docuemnt-list-${id}`], + } + )(); +} + + +// 입력 스키마 정의 +const createDocumentSchema = z.object({ + docNumber: z.string().min(1, "Document number is required"), + title: z.string().min(1, "Title is required"), + status: z.string(), + stages: z.array(z.string()).min(1, "At least one stage is required"), + contractId: z.number().positive("Contract ID is required") +}); + +export type CreateDocumentInputType = z.infer<typeof createDocumentSchema>; + +export async function createDocument(input: CreateDocumentInputType) { + try { + // 입력 유효성 검증 + const validatedData = createDocumentSchema.parse(input); + + // 트랜잭션 사용하여 문서와 스테이지 동시 생성 + return await db.transaction(async (tx) => { + // 1. 문서 생성 + const [newDocument] = await tx + .insert(documents) + .values({ + contractId: validatedData.contractId, + docNumber: validatedData.docNumber, + title: validatedData.title, + status: validatedData.status, + // issuedDate는 선택적으로 추가 가능 + }) + .returning({ id: documents.id }); + + // 2. 스테이지 생성 (문서 ID 연결) + const stageValues = validatedData.stages.map(stageName => ({ + documentId: newDocument.id, + stageName: stageName, + // planDate, actualDate는 나중에 설정 가능 + })); + + // 스테이지 배열 삽입 + await tx.insert(issueStages).values(stageValues); + + // 성공 결과 반환 + return { + success: true, + documentId: newDocument.id, + message: "Document and stages created successfully" + }; + }); + } catch (error) { + console.error("Error creating document:", error); + + // Zod 유효성 검사 에러 처리 + if (error instanceof z.ZodError) { + return { + success: false, + message: "Validation failed", + errors: error.errors + }; + } + + // 기타 에러 처리 + return { + success: false, + message: "Failed to create document" + }; + } +} + +// 캐시 무효화 함수 +export async function invalidateDocumentCache(contractId: number) { + revalidatePath(`/partners/document-list/${contractId}`); + // 추가로 tag 기반 캐시도 무효화할 수 있음 + revalidateTag(`vendor-docuemnt-list-${contractId}`); +} + +const removeDocumentsSchema = z.object({ + ids: z.array(z.number()).min(1, "At least one document ID is required") +}); +export type RemoveDocumentsInputType = z.infer<typeof removeDocumentsSchema>; + +export async function removeDocuments(input: RemoveDocumentsInputType) { + try { + // 입력 유효성 검증 + const validatedData = removeDocumentsSchema.parse(input); + + // 먼저 삭제할 문서의 contractId를 일반 select 쿼리로 가져옴 + const [result] = await db + .select({ contractId: documents.contractId }) + .from(documents) + .where(eq(documents.id, validatedData.ids[0])) + .limit(1); + + const contractId = result?.contractId; + + // 트랜잭션 사용하여 문서 삭제 + await db.transaction(async (tx) => { + // documents 테이블에서 삭제 (cascade 옵션으로 연결된 issueStages도 함께 삭제) + await tx + .delete(documents) + .where(inArray(documents.id, validatedData.ids)); + }); + + // 캐시 무효화 + if (contractId) { + await invalidateDocumentCache(contractId); + } + + return { success: true }; + } catch (error) { + console.error("Error removing documents:", error); + + // Zod 유효성 검사 에러 처리 + if (error instanceof z.ZodError) { + return { + success: false, + error: "Validation failed: " + error.errors.map(e => e.message).join(', ') + }; + } + + // 기타 에러 처리 + return { + success: false, + error: "Failed to remove documents" + }; + } +} + +// 입력 스키마 정의 +const modifyDocumentSchema = z.object({ + id: z.number().positive("Document ID is required"), + contractId: z.number().positive("Contract ID is required"), + docNumber: z.string().min(1, "Document number is required"), + title: z.string().min(1, "Title is required"), + status: z.string().min(1, "Status is required"), + description: z.string().optional(), + remarks: z.string().optional() +}); + +export type ModifyDocumentInputType = z.infer<typeof modifyDocumentSchema>; + +/** + * 문서 정보 수정 서버 액션 + */ +export async function modifyDocument(input: ModifyDocumentInputType) { + try { + // 입력 유효성 검증 + const validatedData = modifyDocumentSchema.parse(input); + + // 업데이트할 문서 데이터 준비 + const updateData = { + docNumber: validatedData.docNumber, + title: validatedData.title, + status: validatedData.status, + description: validatedData.description, + remarks: validatedData.remarks, + updatedAt: new Date() // 수정 시간 업데이트 + }; + + // 트랜잭션 사용하여 문서 업데이트 + const [updatedDocument] = await db.transaction(async (tx) => { + // documents 테이블 업데이트 + return tx + .update(documents) + .set(updateData) + .where(eq(documents.id, validatedData.id)) + .returning({ id: documents.id }); + }); + + // 문서가 존재하지 않는 경우 처리 + if (!updatedDocument) { + return { + success: false, + error: "Document not found" + }; + } + + // 캐시 무효화 + await invalidateDocumentCache(validatedData.contractId); + + // 성공 결과 반환 + return { + success: true, + documentId: updatedDocument.id, + message: "Document updated successfully" + }; + + } catch (error) { + console.error("Error updating document:", error); + + // Zod 유효성 검사 에러 처리 + if (error instanceof z.ZodError) { + return { + success: false, + error: "Validation failed: " + error.errors.map(e => e.message).join(', ') + }; + } + + // 기타 에러 처리 + return { + success: false, + error: getErrorMessage(error) || "Failed to update document" + }; + } +} diff --git a/lib/vendor-document-list/table/add-doc-dialog.tsx b/lib/vendor-document-list/table/add-doc-dialog.tsx new file mode 100644 index 00000000..b108721c --- /dev/null +++ b/lib/vendor-document-list/table/add-doc-dialog.tsx @@ -0,0 +1,299 @@ +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { z } from "zod" +import { Plus, X } from "lucide-react" +import { useRouter } from "next/navigation" + +import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { useToast } from "@/hooks/use-toast" +import { createDocument, CreateDocumentInputType, invalidateDocumentCache } from "../service" + +// Zod 스키마 정의 - 빈 문자열 방지 로직 추가 +const createDocumentSchema = z.object({ + docNumber: z.string().min(1, "Document number is required"), + title: z.string().min(1, "Title is required"), + stages: z.array(z.string().min(1, "Stage name cannot be empty")) + .min(1, "At least one stage is required") + .refine(stages => !stages.some(stage => stage.trim() === ""), { + message: "Stage names cannot be empty" + }) +}); + +type CreateDocumentSchema = z.infer<typeof createDocumentSchema>; + +interface AddDocumentListDialogProps { + projectType: "ship" | "plant"; + contractId: number; +} + +export function AddDocumentListDialog({ projectType, contractId }: AddDocumentListDialogProps) { + const [open, setOpen] = React.useState(false); + const [isSubmitting, setIsSubmitting] = React.useState(false); + const router = useRouter(); + const { toast } = useToast() + + // 기본 스테이지 설정 + const defaultStages = projectType === "ship" + ? ["For Approval", "For Working"] + : [""]; + + // react-hook-form 설정 + const form = useForm<CreateDocumentSchema>({ + resolver: zodResolver(createDocumentSchema), + defaultValues: { + docNumber: "", + title: "", + stages: defaultStages + }, + }); + + // 식물 유형일 때 단계 추가 기능 + const addStage = () => { + const currentStages = form.getValues().stages; + form.setValue('stages', [...currentStages, ""], { shouldValidate: true }); + }; + + // 식물 유형일 때 단계 제거 기능 + const removeStage = (index: number) => { + const currentStages = form.getValues().stages; + const newStages = currentStages.filter((_, i) => i !== index); + form.setValue('stages', newStages, { shouldValidate: true }); + }; + + async function onSubmit(data: CreateDocumentSchema) { + try { + setIsSubmitting(true); + + // 빈 문자열 필터링 (추가 안전장치) + const filteredStages = data.stages.filter(stage => stage.trim() !== ""); + + if (filteredStages.length === 0) { + toast({ + title: "Error", + description: "At least one valid stage name is required", + variant: "destructive", + }); + return; + } + + // 서버 액션 호출 - status를 "pending"으로 설정 + const result = await createDocument({ + ...data, + stages: filteredStages, // 필터링된 단계 사용 + status: "pending", // status 필드 추가 + contractId, // 계약 ID 추가 + } as CreateDocumentInputType); + + if (result.success) { + // 성공 시 캐시 무효화 + await invalidateDocumentCache(contractId); + + // 토스트 메시지 + toast({ + title: "Success", + description: "Document created successfully", + variant: "default", + }); + + // 모달 닫기 및 폼 리셋 + form.reset(); + setOpen(false); + + router.refresh(); + } else { + // 실패 시 에러 토스트 + toast({ + title: "Error", + description: result.message || "Failed to create document", + variant: "destructive", + }); + } + } catch (error) { + console.error('Error creating document:', error); + toast({ + title: "Error", + description: "An unexpected error occurred", + variant: "destructive", + }); + } finally { + setIsSubmitting(false); + } + } + + // 제출 전 유효성 검사 + const validateBeforeSubmit = async () => { + // 빈 스테이지 검사 + const stages = form.getValues().stages; + const hasEmptyStage = stages.some(stage => stage.trim() === ""); + + if (hasEmptyStage) { + form.setError("stages", { + type: "manual", + message: "Stage names cannot be empty" + }); + return false; + } + + return true; + }; + + function handleDialogOpenChange(nextOpen: boolean) { + if (!nextOpen) { + form.reset({ + docNumber: "", + title: "", + stages: defaultStages + }); + } + setOpen(nextOpen); + } + + return ( + <Dialog open={open} onOpenChange={handleDialogOpenChange}> + {/* 모달을 열기 위한 버튼 */} + <DialogTrigger asChild> + <Button variant="default" size="sm"> + <Plus className="size-4 mr-1"/> + Add Document + </Button> + </DialogTrigger> + + <DialogContent className="sm:max-w-[500px]"> + <DialogHeader> + <DialogTitle>Create New Document</DialogTitle> + <DialogDescription> + 새 문서 정보를 입력하고 <b>Create</b> 버튼을 누르세요. + </DialogDescription> + </DialogHeader> + + {/* shadcn/ui Form을 이용해 react-hook-form과 연결 */} + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit, async (errors) => { + // 추가 유효성 검사 수행 + console.error("Form errors:", errors); + const stages = form.getValues().stages; + if (stages.some(stage => stage.trim() === "")) { + toast({ + title: "Error", + description: "Stage names cannot be empty", + variant: "destructive", + }); + } + })} className="space-y-4"> + {/* 문서 번호 필드 */} + <FormField + control={form.control} + name="docNumber" + render={({ field }) => ( + <FormItem> + <FormLabel>Document Number</FormLabel> + <FormControl> + <Input placeholder="Enter document number" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 문서 제목 필드 */} + <FormField + control={form.control} + name="title" + render={({ field }) => ( + <FormItem> + <FormLabel>Title</FormLabel> + <FormControl> + <Input placeholder="Enter document title" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 스테이지 섹션 */} + <div> + <div className="flex items-center justify-between mb-2"> + <FormLabel>Stages</FormLabel> + {projectType === "plant" && ( + <Button + type="button" + variant="outline" + size="sm" + onClick={addStage} + className="h-8 px-2" + > + <Plus className="h-4 w-4 mr-1" /> Add Stage + </Button> + )} + </div> + + {form.watch("stages").map((stage, index) => ( + <div key={index} className="flex items-center gap-2 mb-2"> + <FormField + control={form.control} + name={`stages.${index}`} + render={({ field }) => ( + <FormItem className="flex-1"> + <FormControl> + <Input + placeholder="Enter stage name" + {...field} + disabled={projectType === "ship"} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + {projectType === "plant" && index > 0 && ( + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => removeStage(index)} + className="h-8 w-8 p-0" + > + <X className="h-4 w-4" /> + </Button> + )} + </div> + ))} + <FormMessage> + {form.formState.errors.stages?.message} + </FormMessage> + </div> + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => setOpen(false)} + > + Cancel + </Button> + <Button + type="submit" + disabled={isSubmitting || form.formState.isSubmitting} + > + {isSubmitting ? "Creating..." : "Create"} + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file diff --git a/lib/vendor-document-list/table/delete-docs-dialog.tsx b/lib/vendor-document-list/table/delete-docs-dialog.tsx new file mode 100644 index 00000000..8813c742 --- /dev/null +++ b/lib/vendor-document-list/table/delete-docs-dialog.tsx @@ -0,0 +1,231 @@ +"use client" + +import * as React from "react" +import { type Row } from "@tanstack/react-table" +import { Loader, Trash, AlertCircle } from "lucide-react" +import { toast } from "sonner" + +import { useMediaQuery } from "@/hooks/use-media-query" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { DocumentStagesView } from "@/db/schema/vendorDocu" +import { removeDocuments } from "../service" + +interface DeleteDocumentsDialogProps + extends React.ComponentPropsWithoutRef<typeof Dialog> { + documents: Row<DocumentStagesView>["original"][] + showTrigger?: boolean + onSuccess?: () => void +} + +export function DeleteDocumentsDialog({ + documents, + showTrigger = true, + onSuccess, + ...props +}: DeleteDocumentsDialogProps) { + const [isDeletePending, startDeleteTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + + // "pending" 상태인 문서만 필터링 + const pendingDocuments = documents.filter(doc => doc.status === "pending") + const nonPendingDocuments = documents.filter(doc => doc.status !== "pending") + + const hasMixedStatus = pendingDocuments.length > 0 && nonPendingDocuments.length > 0 + const hasNoPendingDocuments = pendingDocuments.length === 0 + + function onDelete() { + // 삭제할 문서가 없으면 경고 + if (pendingDocuments.length === 0) { + toast.error("No pending documents to delete") + props.onOpenChange?.(false) + return + } + + startDeleteTransition(async () => { + // "pending" 상태인 문서 ID만 전달 + const { success, error } = await removeDocuments({ + ids: pendingDocuments.map((document) => document.documentId) + }) + + if (!success) { + toast.error(error || "Failed to delete documents") + return + } + + props.onOpenChange?.(false) + + // 적절한 성공 메시지 표시 + if (hasMixedStatus) { + toast.success(`${pendingDocuments.length} pending document(s) deleted successfully. ${nonPendingDocuments.length} non-pending document(s) were not affected.`) + } else { + toast.success(`${pendingDocuments.length} document(s) deleted successfully`) + } + + onSuccess?.() + }) + } + + // 선택된 문서 상태에 대한 알림 메시지 렌더링 + const renderStatusAlert = () => { + if (hasNoPendingDocuments) { + return ( + <Alert variant="destructive" className="mb-4"> + <AlertCircle className="h-4 w-4" /> + <AlertDescription> + None of the selected documents are in "pending" status. Only pending documents can be deleted. + </AlertDescription> + </Alert> + ) + } + + if (hasMixedStatus) { + return ( + <Alert className="mb-4"> + <AlertCircle className="h-4 w-4" /> + <AlertDescription> + Only the {pendingDocuments.length} document(s) with "pending" status will be deleted. + {nonPendingDocuments.length} document(s) cannot be deleted because they are not in pending status. + </AlertDescription> + </Alert> + ) + } + + return null + } + + if (isDesktop) { + return ( + <Dialog {...props}> + {showTrigger ? ( + <DialogTrigger asChild> + <Button variant="outline" size="sm"> + <Trash className="mr-2 size-4" aria-hidden="true" /> + Delete ({documents.length}) + </Button> + </DialogTrigger> + ) : null} + <DialogContent> + <DialogHeader> + <DialogTitle>Are you absolutely sure?</DialogTitle> + <DialogDescription> + This action cannot be undone. Only documents with "pending" status can be deleted. + </DialogDescription> + </DialogHeader> + + {renderStatusAlert()} + + <div> + {pendingDocuments.length > 0 && ( + <p className="text-sm text-muted-foreground mb-2"> + {pendingDocuments.length} pending document(s) will be deleted: + </p> + )} + {pendingDocuments.length > 0 && ( + <ul className="text-sm list-disc pl-5 mb-4 max-h-40 overflow-y-auto"> + {pendingDocuments.map(doc => ( + <li key={doc.documentId} className="text-muted-foreground">{doc.docNumber} - {doc.title}</li> + ))} + </ul> + )} + </div> + + <DialogFooter className="gap-2 sm:space-x-0"> + <DialogClose asChild> + <Button variant="outline">Cancel</Button> + </DialogClose> + <Button + aria-label="Delete selected rows" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending || pendingDocuments.length === 0} + > + {isDeletePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + Delete {pendingDocuments.length > 0 ? `(${pendingDocuments.length})` : ""} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) + } + + return ( + <Drawer {...props}> + {showTrigger ? ( + <DrawerTrigger asChild> + <Button variant="outline" size="sm"> + <Trash className="mr-2 size-4" aria-hidden="true" /> + Delete ({documents.length}) + </Button> + </DrawerTrigger> + ) : null} + <DrawerContent> + <DrawerHeader> + <DrawerTitle>Are you absolutely sure?</DrawerTitle> + <DrawerDescription> + This action cannot be undone. Only documents with "pending" status can be deleted. + </DrawerDescription> + </DrawerHeader> + + {renderStatusAlert()} + + <div className="px-4"> + {pendingDocuments.length > 0 && ( + <p className="text-sm text-muted-foreground mb-2"> + {pendingDocuments.length} pending document(s) will be deleted: + </p> + )} + {pendingDocuments.length > 0 && ( + <ul className="text-sm list-disc pl-5 mb-4 max-h-40 overflow-y-auto"> + {pendingDocuments.map(doc => ( + <li key={doc.documentId} className="text-muted-foreground">{doc.docNumber} - {doc.title}</li> + ))} + </ul> + )} + </div> + + <DrawerFooter className="gap-2 sm:space-x-0"> + <DrawerClose asChild> + <Button variant="outline">Cancel</Button> + </DrawerClose> + <Button + aria-label="Delete selected rows" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending || pendingDocuments.length === 0} + > + {isDeletePending && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + Delete {pendingDocuments.length > 0 ? `(${pendingDocuments.length})` : ""} + </Button> + </DrawerFooter> + </DrawerContent> + </Drawer> + ) +}
\ No newline at end of file diff --git a/lib/vendor-document-list/table/doc-table-column.tsx b/lib/vendor-document-list/table/doc-table-column.tsx new file mode 100644 index 00000000..30fb06b0 --- /dev/null +++ b/lib/vendor-document-list/table/doc-table-column.tsx @@ -0,0 +1,202 @@ +"use client" + +import * as React from "react" +import { ColumnDef } from "@tanstack/react-table" +import { formatDate, formatDateTime } from "@/lib/utils" +import { Checkbox } from "@/components/ui/checkbox" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { DataTableRowAction } from "@/types/table" +import { DocumentStagesView } from "@/db/schema/vendorDocu" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Button } from "@/components/ui/button" +import { Ellipsis } from "lucide-react" +import { Badge } from "@/components/ui/badge" + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<DocumentStagesView> | null>> +} + +export function getColumns({ + setRowAction, +}: GetColumnsProps): ColumnDef<DocumentStagesView>[] { + return [ + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + size:40, + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: "docNumber", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Doc Number" /> + ), + cell: ({ row }) => <div>{row.getValue("docNumber")}</div>, + meta: { + excelHeader: "Doc Number" + }, + enableResizing: true, + minSize: 50, + size: 100, + }, + { + accessorKey: "title", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Doc title" /> + ), + cell: ({ row }) => <div>{row.getValue("title")}</div>, + meta: { + excelHeader: "Doc title" + }, + enableResizing: true, + minSize: 100, + size: 160, + }, + + { + accessorKey: "stageCount", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Stage Count" /> + ), + cell: ({ row }) => <div>{row.getValue("stageCount")}</div>, + meta: { + excelHeader: "Stage Count" + }, + enableResizing: true, + minSize: 50, + size: 50, + }, + { + accessorKey: "stageList", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Stage List" /> + ), + cell: ({ row }) => { + const stageNames = row.getValue("stageList") as string[] | null + + if (!stageNames || stageNames.length === 0) { + return <span className="text-sm text-muted-foreground italic">No stages</span> + } + + return ( + <div className="flex flex-wrap gap-2"> + {stageNames.map((stageName, idx) => ( + <Badge variant="secondary" key={idx}> + {stageName} + </Badge> + ))} + </div> + ) + }, + enableResizing: true, + minSize: 120, + size: 120, + }, + { + accessorKey: "status", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="status" /> + ), + cell: ({ row }) => <div>{row.getValue("status")}</div>, + meta: { + excelHeader: "status" + }, + enableResizing: true, + minSize: 60, + size: 60, + }, + { + accessorKey: "createdAt", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Created At" /> + ), + cell: ({ cell }) => formatDateTime(cell.getValue() as Date), + meta: { + excelHeader: "created At" + }, + enableResizing: true, + minSize: 120, + size: 120, + }, + { + accessorKey: "updatedAt", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Updated At" /> + ), + cell: ({ cell }) => formatDateTime(cell.getValue() as Date), + meta: { + excelHeader: "updated At" + }, + enableResizing: true, + minSize: 120, + size: 120, + }, + { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-7 p-0 data-[state=open]:bg-muted" + > + <Ellipsis className="size-4" aria-hidden="true" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-40"> + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "update" })} + > + Edit + </DropdownMenuItem> + + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "delete" })} + > + Delete + <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ) + }, + size: 40, + } + ] +}
\ No newline at end of file diff --git a/lib/vendor-document-list/table/doc-table-toolbar-actions.tsx b/lib/vendor-document-list/table/doc-table-toolbar-actions.tsx new file mode 100644 index 00000000..a30384dd --- /dev/null +++ b/lib/vendor-document-list/table/doc-table-toolbar-actions.tsx @@ -0,0 +1,66 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Download, Send, Upload } from "lucide-react" +import { toast } from "sonner" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" +import { DocumentStagesView } from "@/db/schema/vendorDocu" +import { AddDocumentListDialog } from "./add-doc-dialog" +import { DeleteDocumentsDialog } from "./delete-docs-dialog" + + +interface DocTableToolbarActionsProps { + table: Table<DocumentStagesView> + projectType: "ship" | "plant"; + selectedPackageId: number +} + +export function DocTableToolbarActions({ table, projectType, selectedPackageId }: DocTableToolbarActionsProps) { + + + return ( + <div className="flex items-center gap-2"> + {table.getFilteredSelectedRowModel().rows.length > 0 ? ( + <DeleteDocumentsDialog + documents={table + .getFilteredSelectedRowModel() + .rows.map((row) => row.original)} + onSuccess={() => table.toggleAllRowsSelected(false)} + /> + ) : null} + + + <AddDocumentListDialog projectType={projectType} contractId={selectedPackageId} /> + + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(table, { + filename: "Document-list", + excludeColumns: ["select", "actions"], + }) + } + className="gap-2" + > + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Export</span> + </Button> + + + + <Button + variant="samsung" + size="sm" + className="gap-2" + > + <Send className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Send to SHI</span> + </Button> + + </div> + ) +}
\ No newline at end of file diff --git a/lib/vendor-document-list/table/doc-table.tsx b/lib/vendor-document-list/table/doc-table.tsx new file mode 100644 index 00000000..f70ce365 --- /dev/null +++ b/lib/vendor-document-list/table/doc-table.tsx @@ -0,0 +1,110 @@ +"use client" + +import * as React from "react" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" + +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { getColumns } from "./doc-table-column" +import { getVendorDocuments } from "../service" +import { DocumentStagesView } from "@/db/schema/vendorDocu" +import { useEffect } from "react" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import { DocTableToolbarActions } from "./doc-table-toolbar-actions" +import { DeleteDocumentsDialog } from "./delete-docs-dialog" + +interface DocumentListTableProps { + promises: Promise<[Awaited<ReturnType<typeof getVendorDocuments>>]> + selectedPackageId: number + projectType: "ship" | "plant"; +} + +export function DocumentsTable({ + promises, + selectedPackageId, + projectType, +}: DocumentListTableProps) { + // 1) 데이터를 가져옴 (server component -> use(...) pattern) + const [{ data, pageCount }] = React.use(promises) + + console.log(data) + + const [rowAction, setRowAction] = React.useState<DataTableRowAction<DocumentStagesView> | null>(null) + + + + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + // Filter fields + const filterFields: DataTableFilterField<DocumentStagesView>[] = [] + + const advancedFilterFields: DataTableAdvancedFilterField<DocumentStagesView>[] = [ + { + id: "docNumber", + label: "Doc Number", + type: "text", + }, + { + id: "title", + label: "Doc Title", + type: "text", + }, + { + id: "createdAt", + label: "Created at", + type: "date", + }, + { + id: "updatedAt", + label: "Updated at", + type: "date", + }, + ] + + // useDataTable 훅으로 react-table 구성 + const { table } = useDataTable({ + data: data, // <-- 여기서 tableData 사용 + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.documentId), + shallow: false, + clearOnDefault: true, + columnResizeMode: "onEnd", + + }) + return ( + <> + <DataTable table={table} > + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <DocTableToolbarActions table={table} projectType={projectType} selectedPackageId={selectedPackageId} /> + </DataTableAdvancedToolbar> + </DataTable> + + <DeleteDocumentsDialog + open={rowAction?.type === "delete"} + onOpenChange={() => setRowAction(null)} + documents={rowAction?.row.original ? [rowAction?.row.original] : []} + showTrigger={false} + onSuccess={() => rowAction?.row.toggleSelected(false)} + /> + </> + ) +}
\ No newline at end of file diff --git a/lib/vendor-document-list/table/update-doc-sheet.tsx b/lib/vendor-document-list/table/update-doc-sheet.tsx new file mode 100644 index 00000000..3e0ca225 --- /dev/null +++ b/lib/vendor-document-list/table/update-doc-sheet.tsx @@ -0,0 +1,267 @@ +"use client" + +import * as React from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { Loader, Save } from "lucide-react" +import { useForm } from "react-hook-form" +import { toast } from "sonner" +import { z } from "zod" +import { useRouter } from "next/navigation" + +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { modifyDocument } from "../service" + +// Document 수정을 위한 Zod 스키마 정의 +const updateDocumentSchema = z.object({ + docNumber: z.string().min(1, "Document number is required"), + title: z.string().min(1, "Title is required"), + status: z.string().min(1, "Status is required"), + description: z.string().optional(), + remarks: z.string().optional() +}); + +type UpdateDocumentSchema = z.infer<typeof updateDocumentSchema>; + +// 상태 옵션 정의 +const statusOptions = [ + "pending", + "in-progress", + "completed", + "rejected" +]; + +interface UpdateDocumentSheetProps + extends React.ComponentPropsWithRef<typeof Sheet> { + document: { + id: number; + contractId: number; + docNumber: string; + title: string; + status: string; + description?: string | null; + remarks?: string | null; + } | null +} + +export function UpdateDocumentSheet({ document, ...props }: UpdateDocumentSheetProps) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + const router = useRouter() + + const form = useForm<UpdateDocumentSchema>({ + resolver: zodResolver(updateDocumentSchema), + defaultValues: { + docNumber: "", + title: "", + status: "", + description: "", + remarks: "", + }, + }) + + // 폼 초기화 (document가 변경될 때) + React.useEffect(() => { + if (document) { + form.reset({ + docNumber: document.docNumber, + title: document.title, + status: document.status, + description: document.description ?? "", + remarks: document.remarks ?? "", + }); + } + }, [document, form]); + + function onSubmit(input: UpdateDocumentSchema) { + startUpdateTransition(async () => { + if (!document) return + + const result = await modifyDocument({ + id: document.id, + contractId: document.contractId, + ...input, + }) + + if (!result.success) { + if ('error' in result) { + toast.error(result.error) + } else { + toast.error("Failed to update document") + } + return + } + + form.reset() + props.onOpenChange?.(false) + toast.success("Document updated successfully") + router.refresh() + }) + } + + return ( + <Sheet {...props}> + <SheetContent className="flex flex-col gap-6 sm:max-w-md"> + <SheetHeader className="text-left"> + <SheetTitle>Update Document</SheetTitle> + <SheetDescription> + Update the document details and save the changes + </SheetDescription> + </SheetHeader> + <Form {...form}> + <form + onSubmit={form.handleSubmit(onSubmit)} + className="flex flex-col gap-4" + > + {/* 문서 번호 필드 */} + <FormField + control={form.control} + name="docNumber" + render={({ field }) => ( + <FormItem> + <FormLabel>Document Number</FormLabel> + <FormControl> + <Input placeholder="Enter document number" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 문서 제목 필드 */} + <FormField + control={form.control} + name="title" + render={({ field }) => ( + <FormItem> + <FormLabel>Title</FormLabel> + <FormControl> + <Input placeholder="Enter document title" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 상태 필드 */} + <FormField + control={form.control} + name="status" + render={({ field }) => ( + <FormItem> + <FormLabel>Status</FormLabel> + <Select + onValueChange={field.onChange} + defaultValue={field.value} + value={field.value} + > + <FormControl> + <SelectTrigger> + <SelectValue placeholder="Select status" /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectGroup> + {statusOptions.map((status) => ( + <SelectItem key={status} value={status}> + {status.charAt(0).toUpperCase() + status.slice(1)} + </SelectItem> + ))} + </SelectGroup> + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + {/* 설명 필드 */} + <FormField + control={form.control} + name="description" + render={({ field }) => ( + <FormItem> + <FormLabel>Description</FormLabel> + <FormControl> + <Textarea + placeholder="Enter document description" + className="min-h-[80px]" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 비고 필드 */} + <FormField + control={form.control} + name="remarks" + render={({ field }) => ( + <FormItem> + <FormLabel>Remarks</FormLabel> + <FormControl> + <Textarea + placeholder="Enter additional remarks" + className="min-h-[80px]" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <SheetFooter className="gap-2 pt-2 sm:space-x-0"> + <SheetClose asChild> + <Button + type="button" + variant="outline" + onClick={() => form.reset()} + > + Cancel + </Button> + </SheetClose> + <Button disabled={isUpdatePending}> + {isUpdatePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + <Save className="mr-2 size-4" /> Save + </Button> + </SheetFooter> + </form> + </Form> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/lib/vendor-document-list/validations.ts b/lib/vendor-document-list/validations.ts new file mode 100644 index 00000000..036cc6c6 --- /dev/null +++ b/lib/vendor-document-list/validations.ts @@ -0,0 +1,33 @@ +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, +} from "nuqs/server" +import * as z from "zod" + +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" +import { DocumentStagesView } from "@/db/schema/vendorDocu" + +export const searchParamsCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault( + [] + ), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser<DocumentStagesView>().withDefault([ + { id: "createdAt", desc: true }, + ]), + title: parseAsString.withDefault(""), + docNumber: parseAsString.withDefault(""), + + // advanced filter + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), + +}) + + +export type GetVendorDcoumentsSchema = Awaited<ReturnType<typeof searchParamsCache.parse>> diff --git a/lib/vendor-document/repository.ts b/lib/vendor-document/repository.ts new file mode 100644 index 00000000..79e0cf70 --- /dev/null +++ b/lib/vendor-document/repository.ts @@ -0,0 +1,44 @@ +import db from "@/db/db"; +import { vendorDocumentsView } from "@/db/schema/vendorDocu"; +import { + eq, + inArray, + not, + asc, + desc, + and, + ilike, + gte, + lte, + count, + gt, +} from "drizzle-orm"; +import { PgTransaction } from "drizzle-orm/pg-core"; + +export async function selectVendorDocuments( + tx: PgTransaction<any, any, any>, + params: { + where?: any; // drizzle-orm의 조건식 (and, eq...) 등 + orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[]; + offset?: number; + limit?: number; + } +) { + const { where, orderBy, offset = 0, limit = 10 } = params; + + return tx + .select() + .from(vendorDocumentsView) + .where(where) + .orderBy(...(orderBy ?? [])) + .offset(offset) + .limit(limit); +} +/** 총 개수 count */ +export async function countVendorDocuments( + tx: PgTransaction<any, any, any>, + where?: any +) { + const res = await tx.select({ count: count() }).from(vendorDocumentsView).where(where); + return res[0]?.count ?? 0; +} diff --git a/lib/vendor-document/service.ts b/lib/vendor-document/service.ts new file mode 100644 index 00000000..b14a64e0 --- /dev/null +++ b/lib/vendor-document/service.ts @@ -0,0 +1,346 @@ +"use server" + +import { eq, SQL } from "drizzle-orm" +import db from "@/db/db" +import { documentAttachments, documents, issueStages, revisions, vendorDocumentsView } from "@/db/schema/vendorDocu" +import { contracts } from "@/db/schema/vendorData" +import { GetVendorDcoumentsSchema } from "./validations" +import { unstable_cache } from "@/lib/unstable-cache"; +import { filterColumns } from "@/lib/filter-columns"; +import { getErrorMessage } from "@/lib/handle-error"; +import { asc, desc, ilike, inArray, and, gte, lte, not, or , isNotNull, isNull} from "drizzle-orm"; +import { countVendorDocuments, selectVendorDocuments } from "./repository" +import path from "path"; +import fs from "fs/promises"; +import { v4 as uuidv4 } from "uuid" + +/** + * 특정 vendorId에 속한 문서 목록 조회 + */ +export async function getVendorDocumentLists(input: GetVendorDcoumentsSchema, id: number) { + return unstable_cache( + async () => { + try { + const offset = (input.page - 1) * input.perPage; + + // advancedTable 모드면 filterColumns()로 where 절 구성 + const advancedWhere = filterColumns({ + table: vendorDocumentsView, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + let globalWhere + if (input.search) { + const s = `%${input.search}%` + globalWhere = or(ilike(vendorDocumentsView.title, s), ilike(vendorDocumentsView.docNumber, s) + ) + // 필요시 여러 칼럼 OR조건 (status, priority, etc) + } + + const finalWhere = and(advancedWhere, globalWhere, eq(vendorDocumentsView.contractId, id)); + const orderBy = + input.sort.length > 0 + ? input.sort.map((item) => + item.desc ? desc(vendorDocumentsView[item.id]) : asc(vendorDocumentsView[item.id]) + ) + : [asc(vendorDocumentsView.createdAt)]; + + // 트랜잭션 내부에서 Repository 호출 + const { data, total } = await db.transaction(async (tx) => { + const data = await selectVendorDocuments(tx, { + where: finalWhere, + orderBy, + offset, + limit: input.perPage, + }); + const total = await countVendorDocuments(tx, finalWhere); + return { data, total }; + }); + + const pageCount = Math.ceil(total / input.perPage); + + + return { data, pageCount }; + } catch (err) { + // 에러 발생 시 디폴트 + return { data: [], pageCount: 0 }; + } + }, + [JSON.stringify(input), String(id)], // Include id in the cache key + { + revalidate: 3600, + tags: [`vendor-docuemnt-${id}`], + } + )(); +} + + +// getDocumentVersionsByDocId 함수 수정 - 업로더 타입으로 필터링 추가 +export async function getDocumentVersionsByDocId( + docId: number, +) { + // 모든 조건을 배열로 관리 + const conditions: SQL<unknown>[] = [eq(issueStages.documentId, docId)]; + + + + // 쿼리 실행 + const rows = await db + .select({ + // stage 정보 + stageId: issueStages.id, + stageName: issueStages.stageName, + planDate: issueStages.planDate, + actualDate: issueStages.actualDate, + + // revision 정보 + revisionId: revisions.id, + revision: revisions.revision, + uploaderType: revisions.uploaderType, + uploaderName: revisions.uploaderName, + comment: revisions.comment, + status: revisions.status, + approvedDate: revisions.approvedDate, + + // attachment 정보 + attachmentId: documentAttachments.id, + fileName: documentAttachments.fileName, + filePath: documentAttachments.filePath, + fileType: documentAttachments.fileType, + DocumentSubmitDate: revisions.createdAt, + }) + .from(issueStages) + .leftJoin(revisions, eq(issueStages.id, revisions.issueStageId)) + .leftJoin(documentAttachments, eq(revisions.id, documentAttachments.revisionId)) + .where(and(...conditions)) + .orderBy(issueStages.id, revisions.id, documentAttachments.id); + + // 결과를 처리하여 프론트엔드 형식으로 변환 + // 스테이지+리비전별로 그룹화 + const stageRevMap = new Map(); + // 리비전이 있는 스테이지 ID 추적 + const stagesWithRevisions = new Set(); + + for (const row of rows) { + const stageId = row.stageId; + + + // 리비전이 있는 경우 처리 + if (row.revisionId) { + // 리비전이 있는 스테이지 추적 + stagesWithRevisions.add(stageId); + + const key = `${stageId}-${row.revisionId}`; + + if (!stageRevMap.has(key)) { + stageRevMap.set(key, { + id: row.revisionId, + stage: row.stageName, + revision: row.revision, + uploaderType: row.uploaderType, + uploaderName: row.uploaderName || null, + comment: row.comment || null, + status: row.status || null, + planDate: row.planDate, + actualDate: row.actualDate, + approvedDate: row.approvedDate, + DocumentSubmitDate: row.DocumentSubmitDate, + attachments: [] + }); + } + + // attachmentId가 있는 경우에만 첨부파일 추가 + if (row.attachmentId) { + stageRevMap.get(key).attachments.push({ + id: row.attachmentId, + fileName: row.fileName, + filePath: row.filePath, + fileType: row.fileType + }); + } + } + } + + + // 최종 결과 생성 + const result = [ + ...stageRevMap.values() + ]; + + // 스테이지 이름으로 정렬하고, 같은 스테이지 내에서는 리비전이 없는 항목이 먼저 오도록 정렬 + result.sort((a, b) => { + if (a.stage !== b.stage) { + return a.stage.localeCompare(b.stage); + } + + // 같은 스테이지 내에서는 리비전이 없는 항목이 먼저 오도록 + if (a.revision === null) return -1; + if (b.revision === null) return 1; + + // 두 항목 모두 리비전이 있는 경우 리비전 번호로 정렬 + return a.revision - b.revision; + }); + + return result; +} +// createRevisionAction 함수 수정 - 확장된 업로더 타입 지원 +export async function createRevisionAction(formData: FormData) { + + const stage = formData.get("stage") as string | null + const revision = formData.get("revision") as string | null + const docIdStr = formData.get("documentId") as string + const docId = parseInt(docIdStr, 10) + const customFileName = formData.get("customFileName") as string; + + // 업로더 타입 추가 (기본값: "vendor") + const uploaderType = formData.get("uploaderType") as string || "vendor" + const uploaderName = formData.get("uploaderName") as string | null + const comment = formData.get("comment") as string | null + + if (!docId || Number.isNaN(docId)) { + throw new Error("Invalid or missing documentId") + } + if (!stage || !revision) { + throw new Error("Missing stage/revision") + } + + // 업로더 타입 검증 + if (!['vendor', 'client', 'shi'].includes(uploaderType)) { + throw new Error(`Invalid uploaderType: ${uploaderType}. Must be one of: vendor, client, shi`); + } + + // 트랜잭션 시작 + return await db.transaction(async (tx) => { + // (1) issueStageId 찾기 (stageName + documentId) + let issueStageId: number; + const stageRecord = await tx + .select() + .from(issueStages) + .where(and(eq(issueStages.stageName, stage), eq(issueStages.documentId, docId))) + .limit(1) + + if (!stageRecord.length) { + // Stage가 없으면 새로 생성 + const [newStage] = await tx + .insert(issueStages) + .values({ + documentId: docId, + stageName: stage, + updatedAt: new Date(), + }) + .returning() + + issueStageId = newStage.id + } else { + issueStageId = stageRecord[0].id + } + + // (2) Revision 찾기 또는 생성 (issueStageId + revision 조합) + let revisionId: number; + const revisionRecord = await tx + .select() + .from(revisions) + .where(and(eq(revisions.issueStageId, issueStageId), eq(revisions.revision, revision))) + .limit(1) + + // 기본 상태값 설정 + let status = 'submitted'; + if (uploaderType === 'client') status = 'reviewed'; + if (uploaderType === 'shi') status = 'official'; + + if (!revisionRecord.length) { + // Revision이 없으면 새로 생성 + const [newRevision] = await tx + .insert(revisions) + .values({ + issueStageId, + revision, + uploaderType, + uploaderName: uploaderName || undefined, + comment: comment || undefined, + status, + updatedAt: new Date(), + }) + .returning() + + revisionId = newRevision.id + } else { + // 이미 존재하는 경우, 업로더 타입이 다르면 업데이트 + if (revisionRecord[0].uploaderType !== uploaderType) { + await tx + .update(revisions) + .set({ + uploaderType, + uploaderName: uploaderName || undefined, + comment: comment || undefined, + status, + updatedAt: new Date(), + }) + .where(eq(revisions.id, revisionRecord[0].id)) + } + revisionId = revisionRecord[0].id + } + + // (3) 파일 처리 + const file = formData.get("attachment") as File | null + let attachmentRecord: typeof documentAttachments.$inferSelect | null = null; + + if (file && file.size > 0) { + const originalName = customFileName + const ext = path.extname(originalName) + const uniqueName = uuidv4() + ext + const baseDir = path.join(process.cwd(), "public", "documents") + const savePath = path.join(baseDir, uniqueName) + + const arrayBuffer = await file.arrayBuffer() + const buffer = Buffer.from(arrayBuffer) + await fs.writeFile(savePath, buffer) + + // 파일 정보를 documentAttachments 테이블에 저장 + const result = await tx + .insert(documentAttachments) + .values({ + revisionId, + fileName: originalName, + filePath: "/documents/" + uniqueName, + fileSize: file.size, + fileType: ext.replace('.', '').toLowerCase(), + updatedAt: new Date(), + }) + .returning() + + // 첫 번째 결과만 할당 + attachmentRecord = result[0] + } + + // (4) Documents 테이블의 updatedAt 갱신 (docId가 documents.id) + await tx + .update(documents) + .set({ updatedAt: new Date() }) + .where(eq(documents.id, docId)) + + return attachmentRecord + }) +} + + +export async function getStageNamesByDocumentId(documentId: number) { + try { + if (!documentId || Number.isNaN(documentId)) { + throw new Error("Invalid document ID"); + } + + const stageRecords = await db + .select({ stageName: issueStages.stageName }) + .from(issueStages) + .where(eq(issueStages.documentId, documentId)) + .orderBy(issueStages.stageName); + + // stageName 배열로 변환 + return stageRecords.map(record => record.stageName); + } catch (error) { + console.error("Error fetching stage names:", error); + return []; // 오류 발생시 빈 배열 반환 + } +}
\ No newline at end of file diff --git a/lib/vendor-document/table/doc-table-column.tsx b/lib/vendor-document/table/doc-table-column.tsx new file mode 100644 index 00000000..e53b03b9 --- /dev/null +++ b/lib/vendor-document/table/doc-table-column.tsx @@ -0,0 +1,150 @@ +"use client" + +import * as React from "react" +import { ColumnDef } from "@tanstack/react-table" +import { formatDate, formatDateTime } from "@/lib/utils" +import { Checkbox } from "@/components/ui/checkbox" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { DataTableRowAction } from "@/types/table" +import { VendorDocumentsView } from "@/db/schema/vendorDocu" + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<VendorDocumentsView> | null>> +} + +export function getColumns({ + setRowAction, +}: GetColumnsProps): ColumnDef<VendorDocumentsView>[] { + return [ + { + id: "select", + // Remove the "Select all" checkbox in header since we're doing single-select + header: () => <span className="sr-only">Select</span>, + cell: ({ row, table }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => { + // If selecting this row + if (value) { + // First deselect all rows (to ensure single selection) + table.toggleAllRowsSelected(false) + // Then select just this row + row.toggleSelected(true) + // Trigger the same action that was in the "Select" button + setRowAction({ row, type: "select" }) + } else { + // Just deselect this row + row.toggleSelected(false) + } + }} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + enableSorting: false, + enableHiding: false, + enableResizing: false, + size: 40, + minSize: 40, + maxSize: 40, + }, + + { + accessorKey: "docNumber", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Doc Number" /> + ), + cell: ({ row }) => <div>{row.getValue("docNumber")}</div>, + meta: { + excelHeader: "Doc Number" + }, + enableResizing: true, + minSize: 100, + size: 160, + }, + { + accessorKey: "title", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Doc title" /> + ), + cell: ({ row }) => <div>{row.getValue("title")}</div>, + meta: { + excelHeader: "Doc title" + }, + enableResizing: true, + minSize: 100, + size: 160, + }, + { + accessorKey: "latestStageName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Latest Stage Name" /> + ), + cell: ({ row }) => <div>{row.getValue("latestStageName")}</div>, + meta: { + excelHeader: "Latest Stage Name" + }, + enableResizing: true, + minSize: 100, + size: 160, + }, + { + accessorKey: "latestStagePlanDate", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Latest Stage Plan Date" /> + ), + cell: ({ cell }) => { + const value = cell.getValue(); + return value ? formatDate(value as Date) : ""; + }, meta: { + excelHeader: "Latest Stage Plan Date" + }, + enableResizing: true, + minSize: 100, + size: 160, + }, + { + accessorKey: "latestStageActualDate", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Latest Stage Actual Date" /> + ), + cell: ({ cell }) => { + const value = cell.getValue(); + return value ? formatDate(value as Date) : ""; + }, meta: { + excelHeader: "Latest Stage Actual Date" + }, + enableResizing: true, + minSize: 100, + size: 160, + }, + { + accessorKey: "latestRevision", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Latest Revision" /> + ), + cell: ({ row }) => <div>{row.getValue("latestRevision")}</div>, + meta: { + excelHeader: "Latest Revision" + }, + enableResizing: true, + minSize: 100, + size: 160, + }, + + { + accessorKey: "updatedAt", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Updated At" /> + ), + cell: ({ cell }) => formatDateTime(cell.getValue() as Date), + meta: { + excelHeader: "updated At" + }, + enableResizing: true, + minSize: 120, + size: 180, + }, + // The "actions" column has been removed + ] +}
\ No newline at end of file diff --git a/lib/vendor-document/table/doc-table-toolbar-actions.tsx b/lib/vendor-document/table/doc-table-toolbar-actions.tsx new file mode 100644 index 00000000..cf4aa7c1 --- /dev/null +++ b/lib/vendor-document/table/doc-table-toolbar-actions.tsx @@ -0,0 +1,57 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Download, Send, Upload } from "lucide-react" +import { toast } from "sonner" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" +import { VendorDocumentsView } from "@/db/schema/vendorDocu" + + +interface DocTableToolbarActionsProps { + table: Table<VendorDocumentsView> +} + +export function DocTableToolbarActions({ table }: DocTableToolbarActionsProps) { + + + return ( + <div className="flex items-center gap-2"> + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(table, { + filename: "tasks", + excludeColumns: ["select", "actions"], + }) + } + className="gap-2" + > + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Export</span> + </Button> + + + <Button + size="sm" + className="gap-2" + > + <Upload className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Bulk File Upload</span> + </Button> + + <Button + variant="samsung" + size="sm" + className="gap-2" + > + <Send className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Send to SHI</span> + </Button> + + </div> + ) +}
\ No newline at end of file diff --git a/lib/vendor-document/table/doc-table.tsx b/lib/vendor-document/table/doc-table.tsx new file mode 100644 index 00000000..dfd906fa --- /dev/null +++ b/lib/vendor-document/table/doc-table.tsx @@ -0,0 +1,124 @@ +"use client" + +import * as React from "react" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" + +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { getColumns } from "./doc-table-column" +import { getVendorDocumentLists } from "../service" +import { VendorDocumentsView } from "@/db/schema/vendorDocu" +import { useEffect } from "react" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import { DocTableToolbarActions } from "./doc-table-toolbar-actions" + +interface DocumentListTableProps { + promises: Promise<[Awaited<ReturnType<typeof getVendorDocumentLists>>]> + selectedPackageId: number + onSelectDocument?: (document: VendorDocumentsView | null) => void +} + +export function DocumentListTable({ + promises, + selectedPackageId, + onSelectDocument +}: DocumentListTableProps) { + // 1) 데이터를 가져옴 (server component -> use(...) pattern) + const [{ data, pageCount }] = React.use(promises) + + console.log(data) + const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorDocumentsView> | null>(null) + + // 3) 행 액션 처리 + useEffect(() => { + if (rowAction) { + // 액션 유형에 따라 처리 + switch (rowAction.type) { + case "select": + // 선택된 문서 처리 + if (onSelectDocument) { + onSelectDocument(rowAction.row.original) + } + break; + case "update": + // 업데이트 처리 로직 + console.log("Update document:", rowAction.row.original) + break; + case "delete": + // 삭제 처리 로직 + console.log("Delete document:", rowAction.row.original) + break; + } + + // 액션 처리 후 rowAction 초기화 + setRowAction(null) + } + }, [rowAction, onSelectDocument]) + + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + // Filter fields + const filterFields: DataTableFilterField<VendorDocumentsView>[] = [] + + const advancedFilterFields: DataTableAdvancedFilterField<VendorDocumentsView>[] = [ + { + id: "docNumber", + label: "Doc Number", + type: "text", + }, + { + id: "title", + label: "Doc Title", + type: "text", + }, + { + id: "createdAt", + label: "Created at", + type: "date", + }, + { + id: "updatedAt", + label: "Updated at", + type: "date", + }, + ] + + // useDataTable 훅으로 react-table 구성 + const { table } = useDataTable({ + data: data, // <-- 여기서 tableData 사용 + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + columnResizeMode: "onEnd", + + }) + return ( + <> + <DataTable table={table} > + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <DocTableToolbarActions table={table}/> + </DataTableAdvancedToolbar> + </DataTable> + </> + ) +}
\ No newline at end of file diff --git a/lib/vendor-document/validations.ts b/lib/vendor-document/validations.ts new file mode 100644 index 00000000..7b8bb5fb --- /dev/null +++ b/lib/vendor-document/validations.ts @@ -0,0 +1,33 @@ +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, +} from "nuqs/server" +import * as z from "zod" + +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" +import { VendorDocumentsView, vendorDocumentsView } from "@/db/schema/vendorDocu" + +export const searchParamsCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault( + [] + ), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser<VendorDocumentsView>().withDefault([ + { id: "createdAt", desc: true }, + ]), + title: parseAsString.withDefault(""), + docNumber: parseAsString.withDefault(""), + + // advanced filter + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), + +}) + + +export type GetVendorDcoumentsSchema = Awaited<ReturnType<typeof searchParamsCache.parse>> diff --git a/lib/vendor-rfq-response/service.ts b/lib/vendor-rfq-response/service.ts new file mode 100644 index 00000000..cba6c414 --- /dev/null +++ b/lib/vendor-rfq-response/service.ts @@ -0,0 +1,301 @@ +import { unstable_cache } from "next/cache"; +import db from "@/db/db"; +import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm"; +import { rfqAttachments, rfqComments, rfqItems } from "@/db/schema/rfq"; +import { vendorResponsesView, vendorTechnicalResponses, vendorCommercialResponses, vendorResponseAttachments } from "@/db/schema/rfq"; +import { items } from "@/db/schema/items"; +import { GetRfqsForVendorsSchema } from "../rfqs/validations"; + + + +export async function getRfqResponsesForVendor(input: GetRfqsForVendorsSchema, vendorId: number) { + return unstable_cache( + async () => { + const offset = (input.page - 1) * input.perPage; + const limit = input.perPage; + + // 1) 메인 쿼리: vendorResponsesView 사용 + const { rows, total } = await db.transaction(async (tx) => { + // 검색 조건 + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + sql`${vendorResponsesView.rfqCode} ILIKE ${s}`, + sql`${vendorResponsesView.projectName} ILIKE ${s}`, + sql`${vendorResponsesView.rfqDescription} ILIKE ${s}` + ); + } + + // 벤더 ID 필터링 + const mainWhere = and(eq(vendorResponsesView.vendorId, vendorId), globalWhere); + + // 정렬: 응답 시간순 + const orderBy = [desc(vendorResponsesView.respondedAt)]; + + // (A) 데이터 조회 + const data = await tx + .select() + .from(vendorResponsesView) + .where(mainWhere) + .orderBy(...orderBy) + .offset(offset) + .limit(limit); + + // (B) 전체 개수 카운트 + const [{ count }] = await tx + .select({ + count: sql<number>`count(*)`.as("count"), + }) + .from(vendorResponsesView) + .where(mainWhere); + + return { rows: data, total: Number(count) }; + }); + + // 2) rfqId 고유 목록 추출 + const distinctRfqs = [...new Set(rows.map((r) => r.rfqId))]; + if (distinctRfqs.length === 0) { + return { data: [], pageCount: 0 }; + } + + // 3) 추가 데이터 조회 + // 3-A) RFQ 아이템 + const itemsAll = await db + .select({ + id: rfqItems.id, + rfqId: rfqItems.rfqId, + itemCode: rfqItems.itemCode, + itemName: items.itemName, + quantity: rfqItems.quantity, + description: rfqItems.description, + uom: rfqItems.uom, + }) + .from(rfqItems) + .leftJoin(items, eq(rfqItems.itemCode, items.itemCode)) + .where(inArray(rfqItems.rfqId, distinctRfqs)); + + // 3-B) RFQ 첨부 파일 (벤더용) + const attachAll = await db + .select() + .from(rfqAttachments) + .where( + and( + inArray(rfqAttachments.rfqId, distinctRfqs), + isNull(rfqAttachments.vendorId) + ) + ); + + // 3-C) RFQ 코멘트 + const commAll = await db + .select() + .from(rfqComments) + .where( + and( + inArray(rfqComments.rfqId, distinctRfqs), + or( + isNull(rfqComments.vendorId), + eq(rfqComments.vendorId, vendorId) + ) + ) + ); + + + // 3-E) 벤더 응답 상세 - 기술 + const technicalResponsesAll = await db + .select() + .from(vendorTechnicalResponses) + .where( + inArray( + vendorTechnicalResponses.responseId, + rows.map((r) => r.responseId) + ) + ); + + // 3-F) 벤더 응답 상세 - 상업 + const commercialResponsesAll = await db + .select() + .from(vendorCommercialResponses) + .where( + inArray( + vendorCommercialResponses.responseId, + rows.map((r) => r.responseId) + ) + ); + + // 3-G) 벤더 응답 첨부 파일 + const responseAttachmentsAll = await db + .select() + .from(vendorResponseAttachments) + .where( + inArray( + vendorResponseAttachments.responseId, + rows.map((r) => r.responseId) + ) + ); + + // 4) 데이터 그룹화 + // RFQ 아이템 그룹화 + const itemsByRfqId = new Map<number, any[]>(); + for (const it of itemsAll) { + if (!itemsByRfqId.has(it.rfqId)) { + itemsByRfqId.set(it.rfqId, []); + } + itemsByRfqId.get(it.rfqId)!.push({ + id: it.id, + itemCode: it.itemCode, + itemName: it.itemName, + quantity: it.quantity, + description: it.description, + uom: it.uom, + }); + } + + // RFQ 첨부 파일 그룹화 + const attachByRfqId = new Map<number, any[]>(); + for (const att of attachAll) { + const rid = att.rfqId!; + if (!attachByRfqId.has(rid)) { + attachByRfqId.set(rid, []); + } + attachByRfqId.get(rid)!.push({ + id: att.id, + fileName: att.fileName, + filePath: att.filePath, + vendorId: att.vendorId, + evaluationId: att.evaluationId, + }); + } + + // RFQ 코멘트 그룹화 + const commByRfqId = new Map<number, any[]>(); + for (const c of commAll) { + const rid = c.rfqId!; + if (!commByRfqId.has(rid)) { + commByRfqId.set(rid, []); + } + commByRfqId.get(rid)!.push({ + id: c.id, + commentText: c.commentText, + vendorId: c.vendorId, + evaluationId: c.evaluationId, + createdAt: c.createdAt, + }); + } + + + // 기술 응답 그룹화 + const techResponseByResponseId = new Map<number, any>(); + for (const tr of technicalResponsesAll) { + techResponseByResponseId.set(tr.responseId, { + id: tr.id, + summary: tr.summary, + notes: tr.notes, + createdAt: tr.createdAt, + updatedAt: tr.updatedAt, + }); + } + + // 상업 응답 그룹화 + const commResponseByResponseId = new Map<number, any>(); + for (const cr of commercialResponsesAll) { + commResponseByResponseId.set(cr.responseId, { + id: cr.id, + totalPrice: cr.totalPrice, + currency: cr.currency, + paymentTerms: cr.paymentTerms, + incoterms: cr.incoterms, + deliveryPeriod: cr.deliveryPeriod, + warrantyPeriod: cr.warrantyPeriod, + validityPeriod: cr.validityPeriod, + priceBreakdown: cr.priceBreakdown, + commercialNotes: cr.commercialNotes, + createdAt: cr.createdAt, + updatedAt: cr.updatedAt, + }); + } + + // 응답 첨부 파일 그룹화 + const respAttachByResponseId = new Map<number, any[]>(); + for (const ra of responseAttachmentsAll) { + const rid = ra.responseId!; + if (!respAttachByResponseId.has(rid)) { + respAttachByResponseId.set(rid, []); + } + respAttachByResponseId.get(rid)!.push({ + id: ra.id, + fileName: ra.fileName, + filePath: ra.filePath, + attachmentType: ra.attachmentType, + description: ra.description, + uploadedAt: ra.uploadedAt, + uploadedBy: ra.uploadedBy, + }); + } + + // 5) 최종 데이터 결합 + const final = rows.map((row) => { + return { + // 응답 정보 + responseId: row.responseId, + responseStatus: row.responseStatus, + respondedAt: row.respondedAt, + + // RFQ 기본 정보 + rfqId: row.rfqId, + rfqCode: row.rfqCode, + rfqDescription: row.rfqDescription, + rfqDueDate: row.rfqDueDate, + rfqStatus: row.rfqStatus, + rfqType: row.rfqType, + rfqCreatedAt: row.rfqCreatedAt, + rfqUpdatedAt: row.rfqUpdatedAt, + rfqCreatedBy: row.rfqCreatedBy, + + // 프로젝트 정보 + projectId: row.projectId, + projectCode: row.projectCode, + projectName: row.projectName, + + // 벤더 정보 + vendorId: row.vendorId, + vendorName: row.vendorName, + vendorCode: row.vendorCode, + + // RFQ 관련 데이터 + items: itemsByRfqId.get(row.rfqId) || [], + attachments: attachByRfqId.get(row.rfqId) || [], + comments: commByRfqId.get(row.rfqId) || [], + + // 평가 정보 + tbeEvaluation: row.tbeId ? { + id: row.tbeId, + result: row.tbeResult, + } : null, + cbeEvaluation: row.cbeId ? { + id: row.cbeId, + result: row.cbeResult, + } : null, + + // 벤더 응답 상세 + technicalResponse: techResponseByResponseId.get(row.responseId) || null, + commercialResponse: commResponseByResponseId.get(row.responseId) || null, + responseAttachments: respAttachByResponseId.get(row.responseId) || [], + + // 응답 상태 표시 + hasTechnicalResponse: row.hasTechnicalResponse, + hasCommercialResponse: row.hasCommercialResponse, + attachmentCount: row.attachmentCount || 0, + }; + }); + + const pageCount = Math.ceil(total / input.perPage); + return { data: final, pageCount }; + }, + [JSON.stringify(input), `${vendorId}`], + { + revalidate: 600, + tags: ["rfqs-vendor", `vendor-${vendorId}`], + } + )(); +}
\ No newline at end of file diff --git a/lib/vendor-rfq-response/types.ts b/lib/vendor-rfq-response/types.ts new file mode 100644 index 00000000..5dadc89b --- /dev/null +++ b/lib/vendor-rfq-response/types.ts @@ -0,0 +1,76 @@ +// RFQ 아이템 타입 +export interface RfqResponseItem { + id: number; + itemCode: string; + itemName: string; + quantity?: number; + uom?: string; + description?: string | null; +} + +// RFQ 첨부 파일 타입 +export interface RfqResponseAttachment { + id: number; + fileName: string; + filePath: string; + vendorId?: number | null; + evaluationId?: number | null; +} + +// RFQ 코멘트 타입 +export interface RfqResponseComment { + id: number; + commentText: string; + vendorId?: number | null; + evaluationId?: number | null; + createdAt: Date; + commentedBy?: number; +} + +// 최종 RfqResponse 타입 - RFQ 참여 응답만 포함하도록 간소화 +export interface RfqResponse { + // 응답 정보 + responseId: number; + responseStatus: "INVITED" | "ACCEPTED" | "DECLINED" | "REVIEWING" | "RESPONDED"; + respondedAt: Date; + + // RFQ 기본 정보 + rfqId: number; + rfqCode: string; + rfqDescription?: string | null; + rfqDueDate?: Date | null; + rfqStatus: string; + rfqType?: string | null; + rfqCreatedAt: Date; + rfqUpdatedAt: Date; + rfqCreatedBy?: number | null; + + // 프로젝트 정보 + projectId?: number | null; + projectCode?: string | null; + projectName?: string | null; + + // 벤더 정보 + vendorId: number; + vendorName: string; + vendorCode?: string | null; + + // RFQ 관련 데이터 + items: RfqResponseItem[]; + attachments: RfqResponseAttachment[]; + comments: RfqResponseComment[]; +} + +// DataTable 등에서 사용할 수 있도록 id 필드를 추가한 확장 타입 +export interface RfqResponseWithId extends RfqResponse { + id: number; // rfqId와 동일하게 사용 +} + +// 페이지네이션 결과 타입 +export interface RfqResponsesResult { + data: RfqResponseWithId[]; + pageCount: number; +} + +// 이전 버전과의 호환성을 위한 RfqWithAll 타입 (이름만 유지) +export type RfqWithAll = RfqResponseWithId;
\ No newline at end of file diff --git a/lib/vendor-rfq-response/vendor-rfq-table/ItemsDialog.tsx b/lib/vendor-rfq-response/vendor-rfq-table/ItemsDialog.tsx new file mode 100644 index 00000000..504fc177 --- /dev/null +++ b/lib/vendor-rfq-response/vendor-rfq-table/ItemsDialog.tsx @@ -0,0 +1,125 @@ +"use client" + +import * as React from "react" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { + Table, + TableBody, + TableCaption, + TableCell, + TableFooter, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { RfqWithAll } from "../types" +/** + * 아이템 구조 예시 + * - API 응답에서 quantity가 "string" 형태이므로, + * 숫자로 사용하실 거라면 parse 과정이 필요할 수 있습니다. + */ +export interface RfqItem { + id: number + itemCode: string + itemName: string + quantity: string + description: string + uom: string +} + +/** + * 첨부파일 구조 예시 + */ +export interface RfqAttachment { + id: number + fileName: string + filePath: string + vendorId: number | null + evaluationId: number | null +} + + +/** + * 다이얼로그 내에서만 사용할 단순 아이템 구조 (예: 임시/기본값 표출용) + */ +export interface DefaultItem { + id?: number + itemCode: string + description?: string | null + quantity?: number | null + uom?: string | null +} + +/** + * RfqsItemsDialog 컴포넌트 Prop 타입 + */ +export interface RfqsItemsDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + rfq: RfqWithAll + defaultItems?: DefaultItem[] +} + +export function RfqsItemsDialog({ + open, + onOpenChange, + rfq, +}: RfqsItemsDialogProps) { + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-none w-[1200px]"> + <DialogHeader> + <DialogTitle>Items for RFQ {rfq?.rfqCode}</DialogTitle> + <DialogDescription> + Below is the list of items for this RFQ. + </DialogDescription> + </DialogHeader> + + <div className="overflow-x-auto w-full space-y-4"> + {rfq && rfq.items.length === 0 && ( + <p className="text-sm text-muted-foreground">No items found.</p> + )} + {rfq && rfq.items.length > 0 && ( + <Table> + {/* 필요에 따라 TableCaption 등을 추가해도 좋습니다. */} + <TableHeader> + <TableRow> + <TableHead>Item Code</TableHead> + <TableHead>Item Code</TableHead> + <TableHead>Description</TableHead> + <TableHead>Qty</TableHead> + <TableHead>UoM</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {rfq.items.map((it, idx) => ( + <TableRow key={it.id ?? idx}> + <TableCell>{it.itemCode || "No Code"}</TableCell> + <TableCell>{it.itemName || "No Name"}</TableCell> + <TableCell>{it.description || "-"}</TableCell> + <TableCell>{it.quantity ?? 1}</TableCell> + <TableCell>{it.uom ?? "each"}</TableCell> + </TableRow> + ))} + </TableBody> + </Table> + )} + </div> + + <DialogFooter className="mt-4"> + <Button type="button" variant="outline" onClick={() => onOpenChange(false)}> + Close + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/vendor-rfq-response/vendor-rfq-table/attachment-rfq-sheet.tsx b/lib/vendor-rfq-response/vendor-rfq-table/attachment-rfq-sheet.tsx new file mode 100644 index 00000000..6c51c12c --- /dev/null +++ b/lib/vendor-rfq-response/vendor-rfq-table/attachment-rfq-sheet.tsx @@ -0,0 +1,106 @@ +"use client" + +import * as React from "react" +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetDescription, + SheetFooter, + SheetClose, +} from "@/components/ui/sheet" +import { Button } from "@/components/ui/button" +import { Download } from "lucide-react" +import { formatDate } from "@/lib/utils" + +// 첨부파일 구조 +interface RfqAttachment { + id: number + fileName: string + filePath: string + createdAt?: Date // or Date + vendorId?: number | null + size?: number +} + +// 컴포넌트 Prop +interface RfqAttachmentsSheetProps extends React.ComponentPropsWithRef<typeof Sheet> { + rfqId: number + attachments?: RfqAttachment[] +} + +/** + * RfqAttachmentsSheet: + * - 단순히 첨부파일 리스트 + 다운로드 버튼만 + */ +export function RfqAttachmentsSheet({ + rfqId, + attachments = [], + ...props +}: RfqAttachmentsSheetProps) { + return ( + <Sheet {...props}> + <SheetContent className="flex flex-col gap-6 sm:max-w-sm"> + <SheetHeader> + <SheetTitle>Attachments</SheetTitle> + <SheetDescription>RFQ #{rfqId}에 대한 첨부파일 목록</SheetDescription> + </SheetHeader> + + <div className="space-y-2"> + {/* 첨부파일이 없을 경우 */} + {attachments.length === 0 && ( + <p className="text-sm text-muted-foreground"> + No attachments + </p> + )} + + {/* 첨부파일 목록 */} + {attachments.map((att) => ( + <div + key={att.id} + className="flex items-center justify-between rounded border p-2" + > + <div className="flex flex-col text-sm"> + <span className="font-medium">{att.fileName}</span> + {att.size && ( + <span className="text-xs text-muted-foreground"> + {Math.round(att.size / 1024)} KB + </span> + )} + {att.createdAt && ( + <span className="text-xs text-muted-foreground"> + Created at {formatDate(att.createdAt)} + </span> + )} + </div> + {/* 파일 다운로드 버튼 */} + {att.filePath && ( + <a + href={att.filePath} + download + target="_blank" + rel="noreferrer" + className="text-sm" + > + <Button variant="ghost" size="icon" type="button"> + <Download className="h-4 w-4" /> + </Button> + </a> + )} + </div> + ))} + </div> + + <SheetFooter className="gap-2 pt-2"> + {/* 닫기 버튼 */} + <SheetClose asChild> + <Button type="button" variant="outline"> + Close + </Button> + </SheetClose> + </SheetFooter> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/lib/vendor-rfq-response/vendor-rfq-table/comments-sheet.tsx b/lib/vendor-rfq-response/vendor-rfq-table/comments-sheet.tsx new file mode 100644 index 00000000..d401f1cd --- /dev/null +++ b/lib/vendor-rfq-response/vendor-rfq-table/comments-sheet.tsx @@ -0,0 +1,415 @@ +"use client" + +import * as React from "react" +import { useForm, useFieldArray } from "react-hook-form" +import { z } from "zod" +import { zodResolver } from "@hookform/resolvers/zod" +import { Loader, Download, X } from "lucide-react" +import prettyBytes from "pretty-bytes" +import { toast } from "sonner" + +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Textarea, +} from "@/components/ui/textarea" + +import { + Dropzone, + DropzoneZone, + DropzoneUploadIcon, + DropzoneTitle, + DropzoneDescription, + DropzoneInput +} from "@/components/ui/dropzone" + +import { + Table, + TableHeader, + TableRow, + TableHead, + TableBody, + TableCell +} from "@/components/ui/table" + +// DB 스키마에서 필요한 타입들을 가져온다고 가정 +import { RfqWithAll } from "../types" + +import { createRfqCommentWithAttachments, updateRfqComment } from "../../rfqs/service" +import { formatDate } from "@/lib/utils" + +// 코멘트 + 첨부파일 구조 (단순 예시) +// 실제 DB 스키마에 맞춰 조정 +export interface RfqComment { + id: number + commentText: string + commentedBy?: number + createdAt?: Date + attachments?: { + id: number + fileName: string + filePath?: string + }[] +} + +interface CommentSheetProps extends React.ComponentPropsWithRef<typeof Sheet> { + /** 코멘트를 작성할 RFQ 정보 */ + /** 이미 존재하는 모든 코멘트 목록 (서버에서 불러와 주입) */ + initialComments?: RfqComment[] + + /** 사용자(작성자) ID (로그인 세션 등에서 가져옴) */ + currentUserId: number + rfq:RfqWithAll + /** 댓글 저장 후 갱신용 콜백 (옵션) */ + onCommentsUpdated?: (comments: RfqComment[]) => void +} + +// 새 코멘트 작성 폼 스키마 +const commentFormSchema = z.object({ + commentText: z.string().min(1, "댓글을 입력하세요."), + newFiles: z.array(z.any()).optional() // File[] +}) +type CommentFormValues = z.infer<typeof commentFormSchema> + +const MAX_FILE_SIZE = 30e6 // 30MB + +export function CommentSheet({ + rfq, + initialComments = [], + currentUserId, + onCommentsUpdated, + ...props +}: CommentSheetProps) { + const [comments, setComments] = React.useState<RfqComment[]>(initialComments) + const [isPending, startTransition] = React.useTransition() + + React.useEffect(() => { + setComments(initialComments) + }, [initialComments]) + + // RHF 세팅 + const form = useForm<CommentFormValues>({ + resolver: zodResolver(commentFormSchema), + defaultValues: { + commentText: "", + newFiles: [] + } + }) + + // formFieldArray 예시 (파일 목록) + const { fields: newFileFields, append, remove } = useFieldArray({ + control: form.control, + name: "newFiles" + }) + + // 1) 기존 코멘트 + 첨부 보여주기 + // 간단히 테이블 하나로 표현 + // 실제로는 Bubble 형태의 UI, Accordion, Timeline 등 다양하게 구성할 수 있음 + function renderExistingComments() { + // 1) 편집 상태 관리 + const [editingId, setEditingId] = React.useState<number | null>(null) + const [editText, setEditText] = React.useState("") + + // 2) Edit 시작 핸들러 + function handleEditClick(c: RfqComment) { + setEditingId(c.id) + setEditText(c.commentText) + } + + // 3) Save 핸들러 + async function handleSave(commentId: number) { + try { + // (예시) 서버 액션 or API 요청 + await updateRfqComment({ commentId, commentText: editText }) + + // 만약 단순 로컬 수정만 할 거라면, + // parent state의 comments를 갱신하는 로직 필요 + setComments((prev) => + prev.map((comment) => + comment.id === commentId + ? { ...comment, commentText: editText } + : comment + ) + ) + + toast.success("Comment updated.") + } catch (err) { + toast.error("Error updating comment.") + } finally { + // 편집 모드 종료 + setEditingId(null) + setEditText("") + } + } + + // 4) Cancel 핸들러 + function handleCancel() { + setEditingId(null) + setEditText("") + } + + // 만약 comments가 비어 있다면 + if (comments.length === 0) { + return <p className="text-sm text-muted-foreground">No comments yet</p> + } + + // 5) 테이블 렌더링 + return ( + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-1/2">Comment</TableHead> + <TableHead>Attachments</TableHead> + <TableHead>Created At</TableHead> + <TableHead>Created By</TableHead> + + {/* 추가된 Actions 컬럼 */} + <TableHead>Actions</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {comments.map((c) => ( + <TableRow key={c.id}> + {/* 1) Comment 셀 */} + <TableCell> + {/* 현재 행이 editing 모드인지 체크 */} + {editingId === c.id ? ( + // 편집 모드 + <textarea + value={editText} + onChange={(e) => setEditText(e.target.value)} + className="w-full border p-1 rounded" + rows={3} + /> + ) : ( + // 일반 모드 + c.commentText + )} + </TableCell> + + {/* 2) Attachments 셀 (기존과 동일) */} + <TableCell> + {(!c.attachments || c.attachments.length === 0) && ( + <span className="text-sm text-muted-foreground">No files</span> + )} + {c.attachments && c.attachments.length > 0 && ( + <div className="flex flex-col gap-1"> + {c.attachments.map((att) => ( + <div key={att.id} className="flex items-center gap-2"> + <a + href={att.filePath} + download + target="_blank" + rel="noreferrer" + className="inline-flex items-center gap-1 text-blue-600 underline" + > + <Download className="h-4 w-4" /> + {att.fileName} + </a> + </div> + ))} + </div> + )} + </TableCell> + + {/* 3) Created At */} + <TableCell> { c.createdAt ? formatDate(c.createdAt): "-"}</TableCell> + + {/* 4) Created By */} + <TableCell>{c.commentedBy ?? "-"}</TableCell> + + {/* 5) 새로 추가된 Actions */} + <TableCell> + {editingId === c.id ? ( + // 편집 중일 때 + <div className="flex gap-2"> + <Button variant="outline" size="sm" onClick={() => handleSave(c.id)}> + Save + </Button> + <Button variant="ghost" size="sm" onClick={handleCancel}> + Cancel + </Button> + </div> + ) : ( + // 일반 상태 + <Button variant="outline" size="sm" onClick={() => handleEditClick(c)}> + Edit + </Button> + )} + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + ) + } + + // 2) 새 파일 Drop + function handleDropAccepted(files: File[]) { + // 드롭된 File[]을 RHF field array에 추가 + const toAppend = files.map((f) => f) + append(toAppend) + } + + + // 3) 저장(Submit) + async function onSubmit(data: CommentFormValues) { + + if (!rfq) return + startTransition(async () => { + try { + // 서버 액션 호출 + const res = await createRfqCommentWithAttachments({ + rfqId: rfq.id, + vendorId: rfq.vendorId, // 필요시 세팅 + commentText: data.commentText, + commentedBy: currentUserId, + evaluationId: null, // 필요시 세팅 + files: data.newFiles + }) + + if (!res.ok) { + throw new Error("Failed to create comment") + } + + toast.success("Comment created") + + // 새 코멘트를 다시 불러오거나, + // 여기서는 임시로 "새로운 코멘트가 추가됐다" 라고 가정하여 클라이언트에서 상태 업데이트 + const newComment: RfqComment = { + id: res.commentId, // 서버에서 반환된 commentId + commentText: data.commentText, + commentedBy: currentUserId, + createdAt: res.createdAt, + attachments: (data.newFiles?.map((f, idx) => ({ + id: Math.random() * 100000, + fileName: f.name, + })) || []) + } + setComments((prev) => [...prev, newComment]) + onCommentsUpdated?.([...comments, newComment]) + + // 폼 리셋 + form.reset() + } catch (err: any) { + console.error(err) + toast.error("Error: " + err.message) + } + }) + } + + return ( + <Sheet {...props}> + <SheetContent className="flex flex-col gap-6 sm:max-w-lg"> + <SheetHeader className="text-left"> + <SheetTitle>Comments</SheetTitle> + <SheetDescription> + 필요시 첨부파일과 함께 문의/코멘트를 남길 수 있습니다. + </SheetDescription> + </SheetHeader> + + {/* 기존 코멘트 목록 */} + <div className="max-h-[300px] overflow-y-auto"> + {renderExistingComments()} + </div> + + {/* 새 코멘트 작성 Form */} + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4"> + <FormField + control={form.control} + name="commentText" + render={({ field }) => ( + <FormItem> + <FormLabel>New Comment</FormLabel> + <FormControl> + <Textarea + placeholder="Enter your comment..." + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Dropzone (파일 첨부) */} + <Dropzone + maxSize={MAX_FILE_SIZE} + onDropAccepted={handleDropAccepted} + onDropRejected={(rej) => { + toast.error("File rejected: " + (rej[0]?.file?.name || "")) + }} + > + {({ maxSize }) => ( + <DropzoneZone className="flex justify-center"> + <DropzoneInput /> + <div className="flex items-center gap-6"> + <DropzoneUploadIcon /> + <div className="grid gap-0.5"> + <DropzoneTitle>Drop to attach files</DropzoneTitle> + <DropzoneDescription> + Max size: {prettyBytes(maxSize || 0)} + </DropzoneDescription> + </div> + </div> + </DropzoneZone> + )} + </Dropzone> + + {/* 선택된 파일 목록 */} + {newFileFields.length > 0 && ( + <div className="flex flex-col gap-2"> + {newFileFields.map((field, idx) => { + const file = form.getValues(`newFiles.${idx}`) + if (!file) return null + return ( + <div key={field.id} className="flex items-center justify-between border rounded p-2"> + <span className="text-sm">{file.name} ({prettyBytes(file.size)})</span> + <Button + variant="ghost" + size="icon" + type="button" + onClick={() => remove(idx)} + > + <X className="h-4 w-4" /> + </Button> + </div> + ) + })} + </div> + )} + + <SheetFooter className="gap-2 pt-4"> + <SheetClose asChild> + <Button type="button" variant="outline"> + Cancel + </Button> + </SheetClose> + <Button disabled={isPending}> + {isPending && <Loader className="mr-2 h-4 w-4 animate-spin" />} + Save + </Button> + </SheetFooter> + </form> + </Form> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/lib/vendor-rfq-response/vendor-rfq-table/feature-flags-provider.tsx b/lib/vendor-rfq-response/vendor-rfq-table/feature-flags-provider.tsx new file mode 100644 index 00000000..81131894 --- /dev/null +++ b/lib/vendor-rfq-response/vendor-rfq-table/feature-flags-provider.tsx @@ -0,0 +1,108 @@ +"use client" + +import * as React from "react" +import { useQueryState } from "nuqs" + +import { dataTableConfig, type DataTableConfig } from "@/config/data-table" +import { cn } from "@/lib/utils" +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" + +type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"] + +interface FeatureFlagsContextProps { + featureFlags: FeatureFlagValue[] + setFeatureFlags: (value: FeatureFlagValue[]) => void +} + +const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({ + featureFlags: [], + setFeatureFlags: () => {}, +}) + +export function useFeatureFlags() { + const context = React.useContext(FeatureFlagsContext) + if (!context) { + throw new Error( + "useFeatureFlags must be used within a FeatureFlagsProvider" + ) + } + return context +} + +interface FeatureFlagsProviderProps { + children: React.ReactNode +} + +export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) { + const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>( + "flags", + { + defaultValue: [], + parse: (value) => value.split(",") as FeatureFlagValue[], + serialize: (value) => value.join(","), + eq: (a, b) => + a.length === b.length && a.every((value, index) => value === b[index]), + clearOnDefault: true, + shallow: false, + } + ) + + return ( + <FeatureFlagsContext.Provider + value={{ + featureFlags, + setFeatureFlags: (value) => void setFeatureFlags(value), + }} + > + <div className="w-full overflow-x-auto"> + <ToggleGroup + type="multiple" + variant="outline" + size="sm" + value={featureFlags} + onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)} + className="w-fit gap-0" + > + {dataTableConfig.featureFlags.map((flag, index) => ( + <Tooltip key={flag.value}> + <ToggleGroupItem + value={flag.value} + className={cn( + "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90", + { + "rounded-l-sm border-r-0": index === 0, + "rounded-r-sm": + index === dataTableConfig.featureFlags.length - 1, + } + )} + asChild + > + <TooltipTrigger> + <flag.icon className="size-3.5 shrink-0" aria-hidden="true" /> + {flag.label} + </TooltipTrigger> + </ToggleGroupItem> + <TooltipContent + align="start" + side="bottom" + sideOffset={6} + className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground" + > + <div>{flag.tooltipTitle}</div> + <div className="text-xs text-muted-foreground"> + {flag.tooltipDescription} + </div> + </TooltipContent> + </Tooltip> + ))} + </ToggleGroup> + </div> + {children} + </FeatureFlagsContext.Provider> + ) +} diff --git a/lib/vendor-rfq-response/vendor-rfq-table/rfqs-table-columns.tsx b/lib/vendor-rfq-response/vendor-rfq-table/rfqs-table-columns.tsx new file mode 100644 index 00000000..ac8fa35e --- /dev/null +++ b/lib/vendor-rfq-response/vendor-rfq-table/rfqs-table-columns.tsx @@ -0,0 +1,421 @@ +"use client" + +import * as React from "react" +import { useRouter } from "next/navigation" +import { ColumnDef } from "@tanstack/react-table" +import { + Ellipsis, + MessageSquare, + Package, + Paperclip, +} from "lucide-react" +import { toast } from "sonner" + +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger +} from "@/components/ui/dropdown-menu" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { Badge } from "@/components/ui/badge" + +import { getErrorMessage } from "@/lib/handle-error" +import { formatDate, formatDateTime } from "@/lib/utils" +import { modifyRfqVendor } from "../../rfqs/service" +import type { RfqWithAll } from "../types" +import type { DataTableRowAction } from "@/types/table" + +type NextRouter = ReturnType<typeof useRouter> + +interface GetColumnsProps { + setRowAction: React.Dispatch< + React.SetStateAction<DataTableRowAction<RfqWithAll> | null> + > + router: NextRouter + openAttachmentsSheet: (rfqId: number) => void + openCommentSheet: (rfqId: number) => void +} + +/** + * tanstack table 컬럼 정의 (Nested Header) + */ +export function getColumns({ + setRowAction, + router, + openAttachmentsSheet, + openCommentSheet, +}: GetColumnsProps): ColumnDef<RfqWithAll>[] { + // 1) 체크박스(Select) 컬럼 + const selectColumn: ColumnDef<RfqWithAll> = { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + } + + // 2) Actions (Dropdown) + const actionsColumn: ColumnDef<RfqWithAll> = { + id: "actions", + enableHiding: false, + cell: ({ row }) => { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="ghost" size="icon"> + <Ellipsis className="h-4 w-4" aria-hidden="true" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-56"> + <DropdownMenuSub> + <DropdownMenuSubTrigger>RFQ Response</DropdownMenuSubTrigger> + <DropdownMenuSubContent> + <DropdownMenuRadioGroup + value={row.original.responseStatus} + onValueChange={(value) => { + startUpdateTransition(async () => { + let newStatus: + | "ACCEPTED" + | "DECLINED" + | "REVIEWING" + + switch (value) { + case "ACCEPTED": + newStatus = "ACCEPTED" + break + case "DECLINED": + newStatus = "DECLINED" + break + default: + newStatus = "REVIEWING" + } + + await toast.promise( + modifyRfqVendor({ + id: row.original.responseId, + status: newStatus, + }), + { + loading: "Updating response status...", + success: "Response status updated", + error: (err) => getErrorMessage(err), + } + ) + }) + }} + > + {[ + { value: "ACCEPTED", label: "Accept RFQ" }, + { value: "DECLINED", label: "Decline RFQ" }, + ].map((rep) => ( + <DropdownMenuRadioItem + key={rep.value} + value={rep.value} + className="capitalize" + disabled={isUpdatePending} + > + {rep.label} + </DropdownMenuRadioItem> + ))} + </DropdownMenuRadioGroup> + </DropdownMenuSubContent> + </DropdownMenuSub> + {/* <DropdownMenuItem + onClick={() => { + router.push(`/vendor/rfqs/${row.original.rfqId}`) + }} + > + View Details + </DropdownMenuItem> */} + {/* <DropdownMenuItem onClick={() => openAttachmentsSheet(row.original.rfqId)}> + View Attachments + </DropdownMenuItem> + <DropdownMenuItem onClick={() => openCommentSheet(row.original.rfqId)}> + View Comments + </DropdownMenuItem> + <DropdownMenuItem onClick={() => setRowAction({ row, type: "items" })}> + View Items + </DropdownMenuItem> */} + </DropdownMenuContent> + </DropdownMenu> + ) + }, + size: 40, + } + + // 3) RFQ Code 컬럼 + const rfqCodeColumn: ColumnDef<RfqWithAll> = { + id: "rfqCode", + accessorKey: "rfqCode", + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="RFQ Code" /> + ), + cell: ({ row }) => { + return ( + <Button + variant="link" + className="p-0 h-auto font-medium" + onClick={() => router.push(`/vendor/rfqs/${row.original.rfqId}`)} + > + {row.original.rfqCode} + </Button> + ) + }, + size: 150, + } + + // 4) 응답 상태 컬럼 + const responseStatusColumn: ColumnDef<RfqWithAll> = { + id: "responseStatus", + accessorKey: "responseStatus", + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Response Status" /> + ), + cell: ({ row }) => { + const status = row.original.responseStatus; + let variant: "default" | "secondary" | "destructive" | "outline"; + + switch (status) { + case "REVIEWING": + variant = "default"; + break; + case "ACCEPTED": + variant = "secondary"; + break; + case "DECLINED": + variant = "destructive"; + break; + default: + variant = "outline"; + } + + return <Badge variant={variant}>{status}</Badge>; + }, + size: 150, + } + + // 5) 프로젝트 이름 컬럼 + const projectNameColumn: ColumnDef<RfqWithAll> = { + id: "projectName", + accessorKey: "projectName", + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Project" /> + ), + cell: ({ row }) => row.original.projectName || "-", + size: 150, + } + + // 6) RFQ Description 컬럼 + const descriptionColumn: ColumnDef<RfqWithAll> = { + id: "rfqDescription", + accessorKey: "rfqDescription", + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Description" /> + ), + cell: ({ row }) => row.original.rfqDescription || "-", + size: 200, + } + + // 7) Due Date 컬럼 + const dueDateColumn: ColumnDef<RfqWithAll> = { + id: "rfqDueDate", + accessorKey: "rfqDueDate", + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Due Date" /> + ), + cell: ({ row }) => { + const date = row.original.rfqDueDate; + return date ? formatDate(date) : "-"; + }, + size: 120, + } + + // 8) Last Updated 컬럼 + const updatedAtColumn: ColumnDef<RfqWithAll> = { + id: "respondedAt", + accessorKey: "respondedAt", + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Last Updated" /> + ), + cell: ({ row }) => { + const date = row.original.respondedAt; + return date ? formatDateTime(date) : "-"; + }, + size: 150, + } + + // 9) Items 컬럼 - 뱃지로 아이템 개수 표시 + const itemsColumn: ColumnDef<RfqWithAll> = { + id: "items", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Items" /> + ), + cell: ({ row }) => { + const rfq = row.original + const count = rfq.items?.length ?? 0 + + function handleClick() { + setRowAction({ row, type: "items" }) + } + + return ( + <Button + variant="ghost" + size="sm" + className="relative h-8 w-8 p-0 group" + onClick={handleClick} + aria-label={count > 0 ? `View ${count} items` : "No items"} + > + <Package className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> + {count > 0 && ( + <Badge + variant="secondary" + className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center" + > + {count} + </Badge> + )} + + <span className="sr-only"> + {count > 0 ? `${count} Items` : "No Items"} + </span> + </Button> + ) + }, + enableSorting: false, + maxSize: 80, + } + + // 10) Attachments 컬럼 - 뱃지로 파일 개수 표시 + const attachmentsColumn: ColumnDef<RfqWithAll> = { + id: "attachments", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Attachments" /> + ), + cell: ({ row }) => { + const attachCount = row.original.attachments?.length ?? 0 + + function handleClick(e: React.MouseEvent<HTMLButtonElement>) { + e.preventDefault() + openAttachmentsSheet(row.original.rfqId) + } + + return ( + <Button + variant="ghost" + size="sm" + className="relative h-8 w-8 p-0 group" + onClick={handleClick} + aria-label={ + attachCount > 0 ? `View ${attachCount} files` : "No files" + } + > + <Paperclip className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> + {attachCount > 0 && ( + <Badge + variant="secondary" + className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center" + > + {attachCount} + </Badge> + )} + <span className="sr-only"> + {attachCount > 0 ? `${attachCount} Files` : "No Files"} + </span> + </Button> + ) + }, + enableSorting: false, + maxSize: 80, + } + + // 11) Comments 컬럼 - 뱃지로 댓글 개수 표시 + const commentsColumn: ColumnDef<RfqWithAll> = { + id: "comments", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Comments" /> + ), + cell: ({ row }) => { + const commCount = row.original.comments?.length ?? 0 + + function handleClick() { + setRowAction({ row, type: "comments" }) + openCommentSheet(row.original.rfqId) + } + + return ( + <Button + variant="ghost" + size="sm" + className="relative h-8 w-8 p-0 group" + onClick={handleClick} + aria-label={ + commCount > 0 ? `View ${commCount} comments` : "No comments" + } + > + <MessageSquare className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> + {commCount > 0 && ( + <Badge + variant="secondary" + className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center" + > + {commCount} + </Badge> + )} + <span className="sr-only"> + {commCount > 0 ? `${commCount} Comments` : "No Comments"} + </span> + </Button> + ) + }, + enableSorting: false, + maxSize: 80, + } + + // 최종 컬럼 구성 - TBE/CBE 관련 컬럼 제외 + return [ + selectColumn, + rfqCodeColumn, + responseStatusColumn, + projectNameColumn, + descriptionColumn, + dueDateColumn, + itemsColumn, + attachmentsColumn, + commentsColumn, + updatedAtColumn, + actionsColumn, + ] +}
\ No newline at end of file diff --git a/lib/vendor-rfq-response/vendor-rfq-table/rfqs-table-toolbar-actions.tsx b/lib/vendor-rfq-response/vendor-rfq-table/rfqs-table-toolbar-actions.tsx new file mode 100644 index 00000000..1bae99ef --- /dev/null +++ b/lib/vendor-rfq-response/vendor-rfq-table/rfqs-table-toolbar-actions.tsx @@ -0,0 +1,40 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Download, Upload } from "lucide-react" +import { toast } from "sonner" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" +import { RfqWithAll } from "../types" + + +interface RfqsTableToolbarActionsProps { + table: Table<RfqWithAll> +} + +export function RfqsVendorTableToolbarActions({ table }: RfqsTableToolbarActionsProps) { + + + return ( + <div className="flex items-center gap-2"> + + {/** 4) Export 버튼 */} + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(table, { + filename: "tasks", + excludeColumns: ["select", "actions"], + }) + } + className="gap-2" + > + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Export</span> + </Button> + </div> + ) +}
\ No newline at end of file diff --git a/lib/vendor-rfq-response/vendor-rfq-table/rfqs-table.tsx b/lib/vendor-rfq-response/vendor-rfq-table/rfqs-table.tsx new file mode 100644 index 00000000..337c2875 --- /dev/null +++ b/lib/vendor-rfq-response/vendor-rfq-table/rfqs-table.tsx @@ -0,0 +1,270 @@ +"use client" + +import * as React from "react" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" +import { useRouter } from "next/navigation" + +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" + +import { useFeatureFlags } from "./feature-flags-provider" +import { getColumns } from "./rfqs-table-columns" +import { RfqWithAll } from "../types" + +import { + fetchRfqAttachments, + fetchRfqAttachmentsbyCommentId, +} from "../../rfqs/service" + +import { RfqsVendorTableToolbarActions } from "./rfqs-table-toolbar-actions" +import { RfqsItemsDialog } from "./ItemsDialog" +import { RfqAttachmentsSheet } from "./attachment-rfq-sheet" +import { CommentSheet, RfqComment } from "./comments-sheet" +import { getRfqResponsesForVendor } from "../service" + +interface RfqsTableProps { + promises: Promise<[Awaited<ReturnType<typeof getRfqResponsesForVendor>>]> +} + +// 코멘트+첨부파일 구조 예시 +export interface RfqCommentWithAttachments extends RfqComment { + attachments?: { + id: number + fileName: string + filePath: string + createdAt?: Date + vendorId?: number | null + size?: number + }[] +} + +export interface ExistingAttachment { + id: number + fileName: string + filePath: string + createdAt?: Date + vendorId?: number | null + size?: number +} + +export interface ExistingItem { + id?: number + itemCode: string + description: string | null + quantity: number | null + uom: string | null +} + +export function RfqsVendorTable({ promises }: RfqsTableProps) { + const { featureFlags } = useFeatureFlags() + + // 1) 테이블 데이터( RFQs ) + const [{ data: responseData, pageCount }] = React.use(promises) + + // 데이터를 RfqWithAll 타입으로 변환 (id 필드 추가) + const data: RfqWithAll[] = React.useMemo(() => { + return responseData.map(item => ({ + ...item, + id: item.rfqId, // id 필드를 rfqId와 동일하게 설정 + })); + }, [responseData]); + + const router = useRouter() + + // 2) 첨부파일 시트 + 관련 상태 + const [attachmentsOpen, setAttachmentsOpen] = React.useState(false) + const [selectedRfqIdForAttachments, setSelectedRfqIdForAttachments] = React.useState<number | null>(null) + const [attachDefault, setAttachDefault] = React.useState<ExistingAttachment[]>([]) + + // 3) 코멘트 시트 + 관련 상태 + const [initialComments, setInitialComments] = React.useState<RfqCommentWithAttachments[]>([]) + const [commentSheetOpen, setCommentSheetOpen] = React.useState(false) + const [selectedRfqIdForComments, setSelectedRfqIdForComments] = React.useState<number | null>(null) + + // 4) rowAction으로 다양한 모달/시트 열기 + const [rowAction, setRowAction] = React.useState<DataTableRowAction<RfqWithAll> | null>(null) + + // 열리고 닫힐 때마다, rowAction 등을 확인해서 시트 열기/닫기 처리 + React.useEffect(() => { + if (rowAction?.type === "comments" && rowAction?.row.original) { + openCommentSheet(rowAction.row.original.id) + } + }, [rowAction]) + + /** + * (A) 코멘트 시트를 열기 전에, + * DB에서 (rfqId에 해당하는) 코멘트들 + 각 코멘트별 첨부파일을 조회. + */ + const openCommentSheet = React.useCallback(async (rfqId: number) => { + setInitialComments([]) + + // 여기서 rowAction을 직접 참조하지 않고, 필요한 데이터만 파라미터로 받기 + const comments = data.find(rfq => rfq.rfqId === rfqId)?.comments || [] + + if (comments && comments.length > 0) { + const commentWithAttachments = await Promise.all( + comments.map(async (c) => { + const attachments = await fetchRfqAttachmentsbyCommentId(c.id) + return { + ...c, + commentedBy: c.commentedBy || 1, + attachments, + } + }) + ) + + setInitialComments(commentWithAttachments) + } + + setSelectedRfqIdForComments(rfqId) + setCommentSheetOpen(true) + }, [data]) // data만 의존성으로 추가 + + /** + * (B) 첨부파일 시트 열기 + */ + const openAttachmentsSheet = React.useCallback(async (rfqId: number) => { + const list = await fetchRfqAttachments(rfqId) + setAttachDefault(list) + setSelectedRfqIdForAttachments(rfqId) + setAttachmentsOpen(true) + }, []) + + // 5) DataTable 컬럼 세팅 + const columns = React.useMemo( + () => + getColumns({ + setRowAction, + router, + openAttachmentsSheet, + openCommentSheet + }), + [setRowAction, router, openAttachmentsSheet, openCommentSheet] + ) + + /** + * 간단한 filterFields 예시 + */ + const filterFields: DataTableFilterField<RfqWithAll>[] = [ + { + id: "rfqCode", + label: "RFQ Code", + placeholder: "Filter RFQ Code...", + }, + { + id: "projectName", + label: "Project", + placeholder: "Filter Project...", + }, + { + id: "rfqDescription", + label: "Description", + placeholder: "Filter Description...", + }, + ] + + /** + * Advanced filter fields 예시 + */ + const advancedFilterFields: DataTableAdvancedFilterField<RfqWithAll>[] = [ + { + id: "rfqCode", + label: "RFQ Code", + type: "text", + }, + { + id: "rfqDescription", + label: "Description", + type: "text", + }, + { + id: "projectCode", + label: "Project Code", + type: "text", + }, + { + id: "projectName", + label: "Project Name", + type: "text", + }, + { + id: "rfqDueDate", + label: "Due Date", + type: "date", + }, + { + id: "responseStatus", + label: "Response Status", + type: "select", + options: [ + { label: "Reviewing", value: "REVIEWING" }, + { label: "Accepted", value: "ACCEPTED" }, + { label: "Declined", value: "DECLINED" }, + ], + } + ] + + // useDataTable() 훅 -> pagination, sorting 등 관리 + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "respondedAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + <DataTable table={table}> + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <RfqsVendorTableToolbarActions table={table} /> + </DataTableAdvancedToolbar> + </DataTable> + + {/* 1) 아이템 목록 Dialog */} + {rowAction?.type === "items" && rowAction?.row.original && ( + <RfqsItemsDialog + open={true} + onOpenChange={() => setRowAction(null)} + rfq={rowAction.row.original} + /> + )} + + {/* 2) 코멘트 시트 */} + {selectedRfqIdForComments && ( + <CommentSheet + currentUserId={1} + open={commentSheetOpen} + onOpenChange={setCommentSheetOpen} + initialComments={initialComments} + rfq={data.find(item => item.rfqId === selectedRfqIdForComments)!} + /> + )} + + {/* 3) 첨부파일 시트 */} + <RfqAttachmentsSheet + open={attachmentsOpen} + onOpenChange={setAttachmentsOpen} + rfqId={selectedRfqIdForAttachments ?? 0} + attachments={attachDefault} + /> + </> + ) +}
\ No newline at end of file diff --git a/lib/vendor-rfq-response/vendor-tbe-table/comments-sheet.tsx b/lib/vendor-rfq-response/vendor-tbe-table/comments-sheet.tsx new file mode 100644 index 00000000..1eee54f5 --- /dev/null +++ b/lib/vendor-rfq-response/vendor-tbe-table/comments-sheet.tsx @@ -0,0 +1,334 @@ +"use client" + +import * as React from "react" +import { useForm, useFieldArray } from "react-hook-form" +import { z } from "zod" +import { zodResolver } from "@hookform/resolvers/zod" +import { Loader, Download, X } from "lucide-react" +import prettyBytes from "pretty-bytes" +import { toast } from "sonner" + +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Textarea, +} from "@/components/ui/textarea" + +import { + Dropzone, + DropzoneZone, + DropzoneUploadIcon, + DropzoneTitle, + DropzoneDescription, + DropzoneInput +} from "@/components/ui/dropzone" + +import { + Table, + TableHeader, + TableRow, + TableHead, + TableBody, + TableCell +} from "@/components/ui/table" + +// DB 스키마에서 필요한 타입들을 가져온다고 가정 +// (실제 프로젝트에 맞춰 import를 수정하세요.) +import { RfqWithAll } from "@/db/schema/rfq" +import { createRfqCommentWithAttachments } from "../../rfqs/service" +import { formatDate } from "@/lib/utils" + +// 코멘트 + 첨부파일 구조 (단순 예시) +// 실제 DB 스키마에 맞춰 조정 +export interface TbeComment { + id: number + commentText: string + commentedBy?: number + createdAt?: string | Date + attachments?: { + id: number + fileName: string + filePath: string + }[] +} + +interface CommentSheetProps extends React.ComponentPropsWithRef<typeof Sheet> { + /** 코멘트를 작성할 RFQ 정보 */ + /** 이미 존재하는 모든 코멘트 목록 (서버에서 불러와 주입) */ + initialComments?: TbeComment[] + + /** 사용자(작성자) ID (로그인 세션 등에서 가져옴) */ + currentUserId: number + rfqId:number + vendorId:number + /** 댓글 저장 후 갱신용 콜백 (옵션) */ + onCommentsUpdated?: (comments: TbeComment[]) => void +} + +// 새 코멘트 작성 폼 스키마 +const commentFormSchema = z.object({ + commentText: z.string().min(1, "댓글을 입력하세요."), + newFiles: z.array(z.any()).optional() // File[] +}) +type CommentFormValues = z.infer<typeof commentFormSchema> + +const MAX_FILE_SIZE = 30e6 // 30MB + +export function CommentSheet({ + rfqId, + vendorId, + initialComments = [], + currentUserId, + onCommentsUpdated, + ...props +}: CommentSheetProps) { + const [comments, setComments] = React.useState<TbeComment[]>(initialComments) + const [isPending, startTransition] = React.useTransition() + + React.useEffect(() => { + setComments(initialComments) + }, [initialComments]) + + + // RHF 세팅 + const form = useForm<CommentFormValues>({ + resolver: zodResolver(commentFormSchema), + defaultValues: { + commentText: "", + newFiles: [] + } + }) + + // formFieldArray 예시 (파일 목록) + const { fields: newFileFields, append, remove } = useFieldArray({ + control: form.control, + name: "newFiles" + }) + + // 1) 기존 코멘트 + 첨부 보여주기 + // 간단히 테이블 하나로 표현 + // 실제로는 Bubble 형태의 UI, Accordion, Timeline 등 다양하게 구성할 수 있음 + function renderExistingComments() { + if (comments.length === 0) { + return <p className="text-sm text-muted-foreground">No comments yet</p> + } + + return ( + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-1/2">Comment</TableHead> + <TableHead>Attachments</TableHead> + <TableHead>Created At</TableHead> + <TableHead>Created By</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {comments.map((c) => ( + <TableRow key={c.id}> + <TableCell>{c.commentText}</TableCell> + <TableCell> + {/* 첨부파일 표시 */} + {(!c.attachments || c.attachments.length === 0) && ( + <span className="text-sm text-muted-foreground">No files</span> + )} + {c.attachments && c.attachments.length > 0 && ( + <div className="flex flex-col gap-1"> + {c.attachments.map((att) => ( + <div key={att.id} className="flex items-center gap-2"> + <a + href={att.filePath} + download + target="_blank" + rel="noreferrer" + className="inline-flex items-center gap-1 text-blue-600 underline" + > + <Download className="h-4 w-4" /> + {att.fileName} + </a> + </div> + ))} + </div> + )} + </TableCell> + <TableCell> { c.createdAt ? formatDate(c.createdAt): "-"}</TableCell> + <TableCell> + {c.commentedBy ?? "-"} + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + ) + } + + // 2) 새 파일 Drop + function handleDropAccepted(files: File[]) { + // 드롭된 File[]을 RHF field array에 추가 + const toAppend = files.map((f) => f) + append(toAppend) + } + + + // 3) 저장(Submit) + async function onSubmit(data: CommentFormValues) { + + if (!rfqId) return + startTransition(async () => { + try { + // 서버 액션 호출 + const res = await createRfqCommentWithAttachments({ + rfqId: rfqId, + vendorId: vendorId, // 필요시 세팅 + commentText: data.commentText, + commentedBy: currentUserId, + evaluationId: null, // 필요시 세팅 + files: data.newFiles + }) + + if (!res.ok) { + throw new Error("Failed to create comment") + } + + toast.success("Comment created") + + // 새 코멘트를 다시 불러오거나, + // 여기서는 임시로 "새로운 코멘트가 추가됐다" 라고 가정하여 클라이언트에서 상태 업데이트 + const newComment: TbeComment = { + id: res.commentId, // 서버에서 반환된 commentId + commentText: data.commentText, + commentedBy: currentUserId, + createdAt: new Date().toISOString(), + attachments: (data.newFiles?.map((f, idx) => ({ + id: Math.random() * 100000, + fileName: f.name, + filePath: "/uploads/" + f.name, + })) || []) + } + setComments((prev) => [...prev, newComment]) + onCommentsUpdated?.([...comments, newComment]) + + // 폼 리셋 + form.reset() + } catch (err: any) { + console.error(err) + toast.error("Error: " + err.message) + } + }) + } + + return ( + <Sheet {...props}> + <SheetContent className="flex flex-col gap-6 sm:max-w-lg"> + <SheetHeader className="text-left"> + <SheetTitle>Comments</SheetTitle> + <SheetDescription> + 필요시 첨부파일과 함께 문의/코멘트를 남길 수 있습니다. + </SheetDescription> + </SheetHeader> + + {/* 기존 코멘트 목록 */} + <div className="max-h-[300px] overflow-y-auto"> + {renderExistingComments()} + </div> + + {/* 새 코멘트 작성 Form */} + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4"> + <FormField + control={form.control} + name="commentText" + render={({ field }) => ( + <FormItem> + <FormLabel>New Comment</FormLabel> + <FormControl> + <Textarea + placeholder="Enter your comment..." + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Dropzone (파일 첨부) */} + <Dropzone + maxSize={MAX_FILE_SIZE} + onDropAccepted={handleDropAccepted} + onDropRejected={(rej) => { + toast.error("File rejected: " + (rej[0]?.file?.name || "")) + }} + > + {({ maxSize }) => ( + <DropzoneZone className="flex justify-center"> + <DropzoneInput /> + <div className="flex items-center gap-6"> + <DropzoneUploadIcon /> + <div className="grid gap-0.5"> + <DropzoneTitle>Drop to attach files</DropzoneTitle> + <DropzoneDescription> + Max size: {prettyBytes(maxSize || 0)} + </DropzoneDescription> + </div> + </div> + </DropzoneZone> + )} + </Dropzone> + + {/* 선택된 파일 목록 */} + {newFileFields.length > 0 && ( + <div className="flex flex-col gap-2"> + {newFileFields.map((field, idx) => { + const file = form.getValues(`newFiles.${idx}`) + if (!file) return null + return ( + <div key={field.id} className="flex items-center justify-between border rounded p-2"> + <span className="text-sm">{file.name} ({prettyBytes(file.size)})</span> + <Button + variant="ghost" + size="icon" + type="button" + onClick={() => remove(idx)} + > + <X className="h-4 w-4" /> + </Button> + </div> + ) + })} + </div> + )} + + <SheetFooter className="gap-2 pt-4"> + <SheetClose asChild> + <Button type="button" variant="outline"> + Cancel + </Button> + </SheetClose> + <Button disabled={isPending}> + {isPending && <Loader className="mr-2 h-4 w-4 animate-spin" />} + Save + </Button> + </SheetFooter> + </form> + </Form> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/lib/vendor-rfq-response/vendor-tbe-table/feature-flags-provider.tsx b/lib/vendor-rfq-response/vendor-tbe-table/feature-flags-provider.tsx new file mode 100644 index 00000000..81131894 --- /dev/null +++ b/lib/vendor-rfq-response/vendor-tbe-table/feature-flags-provider.tsx @@ -0,0 +1,108 @@ +"use client" + +import * as React from "react" +import { useQueryState } from "nuqs" + +import { dataTableConfig, type DataTableConfig } from "@/config/data-table" +import { cn } from "@/lib/utils" +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" + +type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"] + +interface FeatureFlagsContextProps { + featureFlags: FeatureFlagValue[] + setFeatureFlags: (value: FeatureFlagValue[]) => void +} + +const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({ + featureFlags: [], + setFeatureFlags: () => {}, +}) + +export function useFeatureFlags() { + const context = React.useContext(FeatureFlagsContext) + if (!context) { + throw new Error( + "useFeatureFlags must be used within a FeatureFlagsProvider" + ) + } + return context +} + +interface FeatureFlagsProviderProps { + children: React.ReactNode +} + +export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) { + const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>( + "flags", + { + defaultValue: [], + parse: (value) => value.split(",") as FeatureFlagValue[], + serialize: (value) => value.join(","), + eq: (a, b) => + a.length === b.length && a.every((value, index) => value === b[index]), + clearOnDefault: true, + shallow: false, + } + ) + + return ( + <FeatureFlagsContext.Provider + value={{ + featureFlags, + setFeatureFlags: (value) => void setFeatureFlags(value), + }} + > + <div className="w-full overflow-x-auto"> + <ToggleGroup + type="multiple" + variant="outline" + size="sm" + value={featureFlags} + onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)} + className="w-fit gap-0" + > + {dataTableConfig.featureFlags.map((flag, index) => ( + <Tooltip key={flag.value}> + <ToggleGroupItem + value={flag.value} + className={cn( + "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90", + { + "rounded-l-sm border-r-0": index === 0, + "rounded-r-sm": + index === dataTableConfig.featureFlags.length - 1, + } + )} + asChild + > + <TooltipTrigger> + <flag.icon className="size-3.5 shrink-0" aria-hidden="true" /> + {flag.label} + </TooltipTrigger> + </ToggleGroupItem> + <TooltipContent + align="start" + side="bottom" + sideOffset={6} + className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground" + > + <div>{flag.tooltipTitle}</div> + <div className="text-xs text-muted-foreground"> + {flag.tooltipDescription} + </div> + </TooltipContent> + </Tooltip> + ))} + </ToggleGroup> + </div> + {children} + </FeatureFlagsContext.Provider> + ) +} diff --git a/lib/vendor-rfq-response/vendor-tbe-table/tbe-table-columns.tsx b/lib/vendor-rfq-response/vendor-tbe-table/tbe-table-columns.tsx new file mode 100644 index 00000000..34a53d17 --- /dev/null +++ b/lib/vendor-rfq-response/vendor-tbe-table/tbe-table-columns.tsx @@ -0,0 +1,317 @@ +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { Download, MessageSquare, Upload } from "lucide-react" +import { toast } from "sonner" + +import { getErrorMessage } from "@/lib/handle-error" +import { formatDate } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { useRouter } from "next/navigation" + +import { + tbeVendorColumnsConfig, + VendorTbeColumnConfig, + vendorTbeColumnsConfig, + TbeVendorFields, +} from "@/config/vendorTbeColumnsConfig" + +type NextRouter = ReturnType<typeof useRouter> + +interface GetColumnsProps { + setRowAction: React.Dispatch< + React.SetStateAction<DataTableRowAction<TbeVendorFields> | null> + > + router: NextRouter + openCommentSheet: (vendorId: number) => void + handleDownloadTbeTemplate: (tbeId: number, vendorId: number, rfqId: number) => void + handleUploadTbeResponse: (tbeId: number, vendorId: number, rfqId: number, vendorResponseId:number) => void +} + +/** + * tanstack table 컬럼 정의 (중첩 헤더 버전) + */ +export function getColumns({ + setRowAction, + router, + openCommentSheet, + handleDownloadTbeTemplate, + handleUploadTbeResponse, +}: GetColumnsProps): ColumnDef<TbeVendorFields>[] { + // ---------------------------------------------------------------- + // 1) Select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef<TbeVendorFields> = { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + } + + // ---------------------------------------------------------------- + // 2) 그룹화(Nested) 컬럼 구성 + // ---------------------------------------------------------------- + const groupMap: Record<string, ColumnDef<TbeVendorFields>[]> = {} + + tbeVendorColumnsConfig.forEach((cfg) => { + const groupName = cfg.group || "_noGroup" + if (!groupMap[groupName]) { + groupMap[groupName] = [] + } + + // childCol: ColumnDef<TbeVendorFields> + const childCol: ColumnDef<TbeVendorFields> = { + accessorKey: cfg.id, + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title={cfg.label} /> + ), + meta: { + excelHeader: cfg.excelHeader, + group: cfg.group, + type: cfg.type, + }, + maxSize: 120, + // 셀 렌더링 + cell: ({ row, getValue }) => { + // 1) 필드값 가져오기 + const val = getValue() + + if (cfg.id === "vendorStatus") { + const statusVal = row.original.vendorStatus + if (!statusVal) return null + // const Icon = getStatusIcon(statusVal) + return ( + <Badge variant="outline"> + {statusVal} + </Badge> + ) + } + + + if (cfg.id === "rfqVendorStatus") { + const statusVal = row.original.rfqVendorStatus + if (!statusVal) return null + // const Icon = getStatusIcon(statusVal) + const variant = statusVal === "INVITED" ? "default" : statusVal === "REJECTED" ? "destructive" : statusVal === "ACCEPTED" ? "secondary" : "outline" + return ( + <Badge variant={variant}> + {statusVal} + </Badge> + ) + } + + // 예) TBE Updated (날짜) + if (cfg.id === "tbeUpdated") { + const dateVal = val as Date | undefined + if (!dateVal) return null + return formatDate(dateVal) + } + + // 그 외 필드는 기본 값 표시 + return val ?? "" + }, + } + + groupMap[groupName].push(childCol) + }) + + // groupMap → nestedColumns + const nestedColumns: ColumnDef<TbeVendorFields>[] = [] + Object.entries(groupMap).forEach(([groupName, colDefs]) => { + if (groupName === "_noGroup") { + nestedColumns.push(...colDefs) + } else { + nestedColumns.push({ + id: groupName, + header: groupName, + columns: colDefs, + }) + } + }) + + // ---------------------------------------------------------------- + // 3) Comments 컬럼 + // ---------------------------------------------------------------- + const commentsColumn: ColumnDef<TbeVendorFields> = { + id: "comments", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Comments" /> + ), + cell: ({ row }) => { + const vendor = row.original + const commCount = vendor.comments?.length ?? 0 + + function handleClick() { + // rowAction + openCommentSheet + setRowAction({ row, type: "comments" }) + openCommentSheet(vendor.tbeId ?? 0) + } + + return ( + <div> + <Button + variant="ghost" + size="sm" + className="h-8 w-8 p-0 group relative" + onClick={handleClick} + aria-label={commCount > 0 ? `View ${commCount} comments` : "Add comment"} + > + <div className="flex items-center justify-center relative"> + <MessageSquare className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> + </div> + {commCount > 0 && <span className="absolute -top-1 -right-1 inline-flex h-2 w-2 rounded-full bg-red-500"></span>} + <span className="sr-only">{commCount > 0 ? `${commCount} Comments` : "Add Comment"}</span> + </Button> + </div> + ) + }, + enableSorting: false, + maxSize: 80 + } + + // ---------------------------------------------------------------- + // 4) TBE 다운로드 컬럼 - 템플릿 다운로드 기능 + // ---------------------------------------------------------------- + const tbeDownloadColumn: ColumnDef<TbeVendorFields> = { + id: "tbeDownload", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="TBE Sheets" /> + ), + cell: ({ row }) => { + const vendor = row.original + const tbeId = vendor.tbeId + const vendorId = vendor.vendorId + const rfqId = vendor.rfqId + const templateFileCount = vendor.templateFileCount || 0 + + if (!tbeId || !vendorId || !rfqId) { + return <div className="text-center text-muted-foreground">-</div> + } + + // 템플릿 파일이 없으면 다운로드 버튼 비활성화 + const isDisabled = templateFileCount <= 0 + + return ( + <Button + variant="ghost" + size="sm" + className="relative h-8 w-8 p-0 group" + onClick={ + isDisabled + ? undefined + : () => handleDownloadTbeTemplate(tbeId, vendorId, rfqId) + } + aria-label={ + templateFileCount > 0 + ? `TBE 템플릿 다운로드 (${templateFileCount}개)` + : "다운로드할 파일 없음" + } + disabled={isDisabled} + > + <Download className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> + + {/* 파일이 1개 이상인 경우 뱃지로 개수 표시 */} + {templateFileCount > 0 && ( + <Badge + variant="secondary" + className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center" + > + {templateFileCount} + </Badge> + )} + + <span className="sr-only"> + {templateFileCount > 0 + ? `TBE 템플릿 다운로드 (${templateFileCount}개)` + : "다운로드할 파일 없음"} + </span> + </Button> + ) + }, + enableSorting: false, + maxSize: 80, + } + // ---------------------------------------------------------------- + // 5) TBE 업로드 컬럼 - 응답 업로드 기능 + // ---------------------------------------------------------------- + const tbeUploadColumn: ColumnDef<TbeVendorFields> = { + id: "tbeUpload", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Upload Response" /> + ), + cell: ({ row }) => { + const vendor = row.original + const tbeId = vendor.tbeId + const vendorId = vendor.vendorId + const rfqId = vendor.rfqId + const vendorResponseId = vendor.vendorResponseId + const status = vendor.rfqVendorStatus + const hasResponse = vendor.hasResponse || false + + + if (!tbeId || !vendorId || !rfqId || status === "REJECTED") { + return <div className="text-center text-muted-foreground">-</div> + } + + return ( + <div > + <Button + variant="ghost" + size="sm" + className="h-8 w-8 p-0 group relative" + onClick={() => handleUploadTbeResponse(tbeId, vendorId, rfqId, vendorResponseId)} + aria-label={hasResponse ? "TBE 응답 확인" : "TBE 응답 업로드"} + > + <div className="flex items-center justify-center relative"> + <Upload className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> + </div> + {hasResponse && ( + <span className="absolute -top-1 -right-1 inline-flex h-2 w-2 rounded-full" style={{ backgroundColor: '#10B981' }}></span> + )} + <span className="sr-only"> + {"TBE 응답 업로드"} + </span> + </Button> + </div> + ) + }, + enableSorting: false, + maxSize: 80 + } + + // ---------------------------------------------------------------- + // 6) 최종 컬럼 배열 + // ---------------------------------------------------------------- + return [ + selectColumn, + ...nestedColumns, + commentsColumn, + tbeDownloadColumn, + tbeUploadColumn, + ] +}
\ No newline at end of file diff --git a/lib/vendor-rfq-response/vendor-tbe-table/tbe-table.tsx b/lib/vendor-rfq-response/vendor-tbe-table/tbe-table.tsx new file mode 100644 index 00000000..3450a643 --- /dev/null +++ b/lib/vendor-rfq-response/vendor-tbe-table/tbe-table.tsx @@ -0,0 +1,162 @@ +"use client" + +import * as React from "react" +import { useRouter } from "next/navigation" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" + +import { toSentenceCase } from "@/lib/utils" +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import { useFeatureFlags } from "./feature-flags-provider" +import { getColumns } from "./tbe-table-columns" +import { Vendor, vendors } from "@/db/schema/vendors" +import { fetchRfqAttachmentsbyCommentId, getTBEforVendor } from "../../rfqs/service" +import { CommentSheet, TbeComment } from "./comments-sheet" +import { TbeVendorFields } from "@/config/vendorTbeColumnsConfig" +import { useTbeFileHandlers } from "./tbeFileHandler" +import { useSession } from "next-auth/react" + +interface VendorsTableProps { + promises: Promise< + [ + Awaited<ReturnType<typeof getTBEforVendor>>, + ] + > +} + +export function TbeVendorTable({ promises }: VendorsTableProps) { + const { featureFlags } = useFeatureFlags() + const { data: session } = useSession() + const userVendorId = session?.user?.companyId + const userId = Number(session?.user?.id) + // Suspense로 받아온 데이터 + const [{ data, pageCount }] = React.use(promises) + const [rowAction, setRowAction] = React.useState<DataTableRowAction<TbeVendorFields> | null>(null) + + + // router 획득 + const router = useRouter() + + const [initialComments, setInitialComments] = React.useState<TbeComment[]>([]) + const [commentSheetOpen, setCommentSheetOpen] = React.useState(false) + const [selectedRfqIdForComments, setSelectedRfqIdForComments] = React.useState<number | null>(null) + + // TBE 파일 핸들러 훅 사용 + const { + handleDownloadTbeTemplate, + handleUploadTbeResponse, + UploadDialog, + } = useTbeFileHandlers() + + React.useEffect(() => { + if (rowAction?.type === "comments") { + // rowAction가 새로 세팅된 뒤 여기서 openCommentSheet 실행 + openCommentSheet(Number(rowAction.row.original.id)) + } + }, [rowAction]) + + async function openCommentSheet(vendorId: number) { + setInitialComments([]) + + const comments = rowAction?.row.original.comments + + if (comments && comments.length > 0) { + const commentWithAttachments: TbeComment[] = await Promise.all( + comments.map(async (c) => { + // 서버 액션을 사용하여 코멘트 첨부 파일 가져오기 + const attachments = await fetchRfqAttachmentsbyCommentId(c.id) + + return { + ...c, + commentedBy: 1, // DB나 API 응답에 있다고 가정 + attachments, + } + }) + ) + + setInitialComments(commentWithAttachments) + } + + setSelectedRfqIdForComments(vendorId) + setCommentSheetOpen(true) + } + + // getColumns() 호출 시, 필요한 모든 핸들러 함수 주입 + const columns = React.useMemo( + () => getColumns({ + setRowAction, + router, + openCommentSheet, + handleDownloadTbeTemplate, + handleUploadTbeResponse, + }), + [setRowAction, router, openCommentSheet, handleDownloadTbeTemplate, handleUploadTbeResponse] + ) + + const filterFields: DataTableFilterField<TbeVendorFields>[] = [] + + const advancedFilterFields: DataTableAdvancedFilterField<TbeVendorFields>[] = [ + { id: "vendorName", label: "Vendor Name", type: "text" }, + { id: "vendorCode", label: "Vendor Code", type: "text" }, + { id: "email", label: "Email", type: "text" }, + { id: "country", label: "Country", type: "text" }, + { + id: "vendorStatus", + label: "Vendor Status", + type: "multi-select", + options: vendors.status.enumValues.map((status) => ({ + label: toSentenceCase(status), + value: status, + })), + }, + { id: "rfqVendorUpdated", label: "Updated at", type: "date" }, + ] + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "rfqVendorUpdated", desc: true }], + columnPinning: { right: ["comments", "tbeDocuments"] }, // tbeDocuments 컬럼을 우측에 고정 + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + <DataTable table={table}> + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + /> + </DataTable> + + {/* 코멘트 시트 */} + {commentSheetOpen && selectedRfqIdForComments && ( + <CommentSheet + open={commentSheetOpen} + onOpenChange={setCommentSheetOpen} + rfqId={selectedRfqIdForComments} + initialComments={initialComments} + vendorId={userVendorId||0} + currentUserId={userId||0} + /> + )} + + {/* TBE 파일 다이얼로그 */} + <UploadDialog /> + </> + ) +}
\ No newline at end of file diff --git a/lib/vendor-rfq-response/vendor-tbe-table/tbeFileHandler.tsx b/lib/vendor-rfq-response/vendor-tbe-table/tbeFileHandler.tsx new file mode 100644 index 00000000..3994b8eb --- /dev/null +++ b/lib/vendor-rfq-response/vendor-tbe-table/tbeFileHandler.tsx @@ -0,0 +1,355 @@ +"use client"; + +import { useCallback, useState, useEffect } from "react"; +import { toast } from "sonner"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { + fetchTbeTemplateFiles, + getTbeTemplateFileInfo, + uploadTbeResponseFile, + getTbeSubmittedFiles, +} from "../../rfqs/service"; +import { + Dropzone, + DropzoneDescription, + DropzoneInput, + DropzoneTitle, + DropzoneUploadIcon, + DropzoneZone, +} from "@/components/ui/dropzone"; +import { + FileList, + FileListAction, + FileListDescription, + FileListIcon, + FileListInfo, + FileListItem, + FileListName, + FileListSize, +} from "@/components/ui/file-list"; +import { Download, X } from "lucide-react"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { formatDateTime } from "@/lib/utils"; + +export function useTbeFileHandlers() { + // 모달 열림 여부, 현재 선택된 IDs + const [isUploadDialogOpen, setIsUploadDialogOpen] = useState(false); + const [currentTbeId, setCurrentTbeId] = useState<number | null>(null); + const [currentVendorId, setCurrentVendorId] = useState<number | null>(null); + const [currentRfqId, setCurrentRfqId] = useState<number | null>(null); + const [currentvendorResponseId, setCurrentvendorResponseId] = useState<number | null>(null); + + + + // 로딩 상태들 + const [isLoading, setIsLoading] = useState(false); + const [isFetchingFiles, setIsFetchingFiles] = useState(false); + + // 업로드할 파일, 제출된 파일 목록 + const [selectedFile, setSelectedFile] = useState<File | null>(null); + const [submittedFiles, setSubmittedFiles] = useState< + Array<{ id: number; fileName: string; filePath: string; uploadedAt: Date }> + >([]); + + // =================================== + // 1) 제출된 파일 목록 가져오기 + // =================================== + const fetchSubmittedFiles = useCallback(async (vendorResponseId: number) => { + if (!vendorResponseId ) return; + + setIsFetchingFiles(true); + try { + const { files, error } = await getTbeSubmittedFiles(vendorResponseId); + if (error) { + console.error(error); + return; + } + setSubmittedFiles(files); + } catch (error) { + console.error("Failed to fetch submitted files:", error); + } finally { + setIsFetchingFiles(false); + } + }, []); + + // =================================== + // 2) TBE 템플릿 다운로드 + // =================================== + const handleDownloadTbeTemplate = useCallback( + async (tbeId: number, vendorId: number, rfqId: number) => { + setCurrentTbeId(tbeId); + setCurrentVendorId(vendorId); + setCurrentRfqId(rfqId); + setIsLoading(true); + + try { + const { files, error } = await fetchTbeTemplateFiles(tbeId); + if (error) { + toast.error(error); + return; + } + if (files.length === 0) { + toast.warning("다운로드할 템플릿 파일이 없습니다"); + return; + } + // 순차적으로 파일 다운로드 + for (const file of files) { + await downloadFile(file.id); + } + toast.success("모든 템플릿 파일이 다운로드되었습니다"); + } catch (error) { + toast.error("템플릿 파일을 다운로드하는 데 실패했습니다"); + console.error(error); + } finally { + setIsLoading(false); + } + }, + [] + ); + + // 실제 다운로드 로직 + const downloadFile = useCallback(async (fileId: number) => { + try { + const { file, error } = await getTbeTemplateFileInfo(fileId); + if (error || !file) { + throw new Error(error || "파일 정보를 가져오는 데 실패했습니다"); + } + + const link = document.createElement("a"); + link.href = `/api/rfq-download?path=${encodeURIComponent(file.filePath)}`; + link.download = file.fileName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + return true; + } catch (error) { + console.error(error); + return false; + } + }, []); + + // =================================== + // 3) 제출된 파일 다운로드 + // =================================== + const downloadSubmittedFile = useCallback((file: { id: number; fileName: string; filePath: string }) => { + try { + const link = document.createElement("a"); + link.href = `/api/files/${file.filePath}`; + link.download = file.fileName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + toast.success(`${file.fileName} 다운로드 시작`); + } catch (error) { + console.error("Failed to download file:", error); + toast.error("파일 다운로드에 실패했습니다"); + } + }, []); + + // =================================== + // 4) TBE 응답 업로드 모달 열기 + // (이 시점에서는 데이터 fetch하지 않음) + // =================================== + const handleUploadTbeResponse = useCallback((tbeId: number, vendorId: number, rfqId: number, vendorResponseId:number) => { + setCurrentTbeId(tbeId); + setCurrentVendorId(vendorId); + setCurrentRfqId(rfqId); + setCurrentvendorResponseId(vendorResponseId); + setIsUploadDialogOpen(true); + }, []); + + // =================================== + // 5) Dialog 열고 닫힐 때 상태 초기화 + // 열렸을 때 -> useEffect로 파일 목록 가져오기 + // =================================== + useEffect(() => { + if (!isUploadDialogOpen) { + // 닫힐 때는 파일 상태들 초기화 + setSelectedFile(null); + setSubmittedFiles([]); + } + }, [isUploadDialogOpen]); + + useEffect(() => { + // Dialog가 열렸고, ID들이 유효하면 + if (isUploadDialogOpen &¤tvendorResponseId) { + fetchSubmittedFiles(currentvendorResponseId); + } + }, [isUploadDialogOpen, currentvendorResponseId, fetchSubmittedFiles]); + + // =================================== + // 6) 드롭존 파일 선택 & 제거 + // =================================== + const handleFileDrop = useCallback((files: File[]) => { + if (files && files.length > 0) { + setSelectedFile(files[0]); + } + }, []); + + const handleRemoveFile = useCallback(() => { + setSelectedFile(null); + }, []); + + // =================================== + // 7) 응답 파일 업로드 + // =================================== + const handleSubmitResponse = useCallback(async () => { + if (!selectedFile || !currentTbeId || !currentVendorId || !currentRfqId ||!currentvendorResponseId) { + toast.error("업로드할 파일을 선택해주세요"); + return; + } + + setIsLoading(true); + try { + // FormData 생성 + const formData = new FormData(); + formData.append("file", selectedFile); + formData.append("rfqId", currentRfqId.toString()); + formData.append("vendorId", currentVendorId.toString()); + formData.append("evaluationId", currentTbeId.toString()); + formData.append("vendorResponseId", currentvendorResponseId.toString()); + + const result = await uploadTbeResponseFile(formData); + if (!result.success) { + throw new Error(result.error || "파일 업로드에 실패했습니다"); + } + + toast.success(result.message || "응답이 성공적으로 업로드되었습니다"); + + // 업로드 후 다시 제출된 파일 목록 가져오기 + await fetchSubmittedFiles(currentvendorResponseId); + + // 업로드 성공 시 선택 파일 초기화 + setSelectedFile(null); + } catch (error) { + toast.error(error instanceof Error ? error.message : "응답 업로드에 실패했습니다"); + console.error(error); + } finally { + setIsLoading(false); + } + }, [selectedFile, currentTbeId, currentVendorId, currentRfqId, currentvendorResponseId,fetchSubmittedFiles]); + + // =================================== + // 8) 실제 Dialog 컴포넌트 + // =================================== + const UploadDialog = () => ( + <Dialog open={isUploadDialogOpen} onOpenChange={setIsUploadDialogOpen}> + <DialogContent className="sm:max-w-lg"> + <DialogHeader> + <DialogTitle>TBE 응답 파일</DialogTitle> + <DialogDescription>제출된 파일을 확인하거나 새 파일을 업로드하세요.</DialogDescription> + </DialogHeader> + + <Tabs defaultValue="upload" className="w-full"> + <TabsList className="grid w-full grid-cols-2"> + <TabsTrigger value="upload">새 파일 업로드</TabsTrigger> + <TabsTrigger + value="submitted" + disabled={submittedFiles.length === 0} + className={submittedFiles.length > 0 ? "relative" : ""} + > + 제출된 파일{" "} + {submittedFiles.length > 0 && ( + <span className="ml-2 inline-flex items-center justify-center rounded-full bg-primary w-4 h-4 text-[10px] text-primary-foreground"> + {submittedFiles.length} + </span> + )} + </TabsTrigger> + </TabsList> + + {/* 업로드 탭 */} + <TabsContent value="upload" className="pt-4"> + <div className="grid gap-4"> + {selectedFile ? ( + <FileList> + <FileListItem> + <FileListIcon /> + <FileListInfo> + <FileListName>{selectedFile.name}</FileListName> + <FileListSize>{selectedFile.size}</FileListSize> + </FileListInfo> + <FileListAction> + <Button variant="ghost" size="icon" onClick={handleRemoveFile}> + <X className="h-4 w-4" /> + <span className="sr-only">파일 제거</span> + </Button> + </FileListAction> + </FileListItem> + </FileList> + ) : ( + <Dropzone onDrop={handleFileDrop}> + <DropzoneInput className="sr-only" /> + <DropzoneZone className="flex flex-col items-center justify-center gap-2 p-6"> + <DropzoneUploadIcon className="h-10 w-10 text-muted-foreground" /> + <DropzoneTitle>파일을 드래그하거나 클릭하여 업로드</DropzoneTitle> + <DropzoneDescription>TBE 응답 파일 (XLSX, XLS, DOCX, PDF 등)</DropzoneDescription> + </DropzoneZone> + </Dropzone> + )} + + <DialogFooter className="mt-4"> + <Button type="submit" onClick={handleSubmitResponse} disabled={!selectedFile || isLoading}> + {isLoading ? "업로드 중..." : "응답 업로드"} + </Button> + </DialogFooter> + </div> + </TabsContent> + + {/* 제출된 파일 탭 */} + <TabsContent value="submitted" className="pt-4"> + {isFetchingFiles ? ( + <div className="flex justify-center items-center py-8"> + <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div> + </div> + ) : submittedFiles.length > 0 ? ( + <div className="grid gap-2"> + <FileList> + {submittedFiles.map((file) => ( + <FileListItem key={file.id} className="flex items-center justify-between gap-3"> + <div className="flex items-center gap-3 flex-1"> + <FileListIcon className="flex-shrink-0" /> + <FileListInfo className="flex-1 min-w-0"> + <FileListName className="text-sm font-medium truncate">{file.fileName}</FileListName> + <FileListDescription className="text-xs text-muted-foreground"> + {file.uploadedAt ? formatDateTime(file.uploadedAt) : ""} + </FileListDescription> + </FileListInfo> + </div> + <FileListAction className="flex-shrink-0 ml-2"> + <Button variant="ghost" size="icon" onClick={() => downloadSubmittedFile(file)}> + <Download className="h-4 w-4" /> + <span className="sr-only">파일 다운로드</span> + </Button> + </FileListAction> + </FileListItem> + ))} + </FileList> + </div> + ) : ( + <div className="text-center py-8 text-muted-foreground">제출된 파일이 없습니다.</div> + )} + </TabsContent> + </Tabs> + </DialogContent> + </Dialog> + ); + + // =================================== + // 9) Hooks 내보내기 + // =================================== + return { + handleDownloadTbeTemplate, + handleUploadTbeResponse, + UploadDialog, + }; +}
\ No newline at end of file diff --git a/lib/vendors/contacts-table/add-contact-dialog.tsx b/lib/vendors/contacts-table/add-contact-dialog.tsx new file mode 100644 index 00000000..5376583a --- /dev/null +++ b/lib/vendors/contacts-table/add-contact-dialog.tsx @@ -0,0 +1,175 @@ +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" + +import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" + +import { + createVendorContactSchema, + type CreateVendorContactSchema, +} from "../validations" +import { createVendorContact } from "../service" + +interface AddContactDialogProps { + vendorId: number +} + +export function AddContactDialog({ vendorId }: AddContactDialogProps) { + const [open, setOpen] = React.useState(false) + + // react-hook-form 세팅 + const form = useForm<CreateVendorContactSchema>({ + resolver: zodResolver(createVendorContactSchema), + defaultValues: { + // vendorId는 form에 표시할 필요가 없다면 hidden으로 관리하거나, submit 시 추가 + vendorId, + contactName: "", + contactPosition: "", + contactEmail: "", + contactPhone: "", + isPrimary: false, + }, + }) + + async function onSubmit(data: CreateVendorContactSchema) { + // 혹은 여기서 data.vendorId = vendorId; 해줘도 됨 + const result = await createVendorContact(data) + if (result.error) { + alert(`에러: ${result.error}`) + return + } + // 성공 시 모달 닫고 폼 리셋 + form.reset() + setOpen(false) + } + + function handleDialogOpenChange(nextOpen: boolean) { + if (!nextOpen) { + form.reset() + } + setOpen(nextOpen) + } + + return ( + <Dialog open={open} onOpenChange={handleDialogOpenChange}> + {/* 모달을 열기 위한 버튼 */} + <DialogTrigger asChild> + <Button variant="default" size="sm"> + Add Contact + </Button> + </DialogTrigger> + + <DialogContent> + <DialogHeader> + <DialogTitle>Create New Contact</DialogTitle> + <DialogDescription> + 새 Contact 정보를 입력하고 <b>Create</b> 버튼을 누르세요. + </DialogDescription> + </DialogHeader> + + {/* shadcn/ui Form을 이용해 react-hook-form과 연결 */} + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)}> + <div className="space-y-4 py-4"> + <FormField + control={form.control} + name="contactName" + render={({ field }) => ( + <FormItem> + <FormLabel>Contact Name</FormLabel> + <FormControl> + <Input placeholder="예: 홍길동" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="contactPosition" + render={({ field }) => ( + <FormItem> + <FormLabel>Position / Title</FormLabel> + <FormControl> + <Input placeholder="예: 과장" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="contactEmail" + render={({ field }) => ( + <FormItem> + <FormLabel>Email</FormLabel> + <FormControl> + <Input placeholder="name@company.com" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="contactPhone" + render={({ field }) => ( + <FormItem> + <FormLabel>Phone</FormLabel> + <FormControl> + <Input placeholder="010-1234-5678" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 단순 checkbox */} + <FormField + control={form.control} + name="isPrimary" + render={({ field }) => ( + <FormItem> + <div className="flex items-center space-x-2 mt-2"> + <input + type="checkbox" + checked={field.value} + onChange={(e) => field.onChange(e.target.checked)} + /> + <FormLabel>Is Primary?</FormLabel> + </div> + <FormMessage /> + </FormItem> + )} + /> + </div> + + <DialogFooter> + <Button type="button" variant="outline" onClick={() => setOpen(false)}> + Cancel + </Button> + <Button type="submit" disabled={form.formState.isSubmitting}> + Create + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/vendors/contacts-table/contact-table-columns.tsx b/lib/vendors/contacts-table/contact-table-columns.tsx new file mode 100644 index 00000000..f80fae33 --- /dev/null +++ b/lib/vendors/contacts-table/contact-table-columns.tsx @@ -0,0 +1,195 @@ +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { Ellipsis } from "lucide-react" +import { toast } from "sonner" + +import { getErrorMessage } from "@/lib/handle-error" +import { formatDate } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header" + +import { VendorContact, vendors } from "@/db/schema/vendors" +import { modifyVendor } from "../service" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { vendorContactsColumnsConfig } from "@/config/vendorContactsColumnsConfig" + + + + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<VendorContact> | null>>; +} + +/** + * tanstack table 컬럼 정의 (중첩 헤더 버전) + */ +export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<VendorContact>[] { + // ---------------------------------------------------------------- + // 1) select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef<VendorContact> = { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + size:40, + enableSorting: false, + enableHiding: false, + } + + // ---------------------------------------------------------------- + // 2) actions 컬럼 (Dropdown 메뉴) + // ---------------------------------------------------------------- + const actionsColumn: ColumnDef<VendorContact> = { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-8 p-0 data-[state=open]:bg-muted" + > + <Ellipsis className="size-4" aria-hidden="true" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-40"> + <DropdownMenuItem + onSelect={() => { + setRowAction({ row, type: "update" }) + + }} + > + Edit + </DropdownMenuItem> + + <DropdownMenuSeparator /> + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "delete" })} + > + Delete + <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ) + }, + size: 40, + } + + // ---------------------------------------------------------------- + // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 + // ---------------------------------------------------------------- + // 3-1) groupMap: { [groupName]: ColumnDef<VendorContact>[] } + const groupMap: Record<string, ColumnDef<VendorContact>[]> = {} + + vendorContactsColumnsConfig.forEach((cfg) => { + // 만약 group가 없으면 "_noGroup" 처리 + const groupName = cfg.group || "_noGroup" + + if (!groupMap[groupName]) { + groupMap[groupName] = [] + } + + // child column 정의 + const childCol: ColumnDef<VendorContact> = { + accessorKey: cfg.id, + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title={cfg.label} /> + ), + meta: { + excelHeader: cfg.excelHeader, + group: cfg.group, + type: cfg.type, + }, + cell: ({ row, cell }) => { + + + if (cfg.id === "createdAt") { + const dateVal = cell.getValue() as Date + return formatDate(dateVal) + } + + if (cfg.id === "updatedAt") { + const dateVal = cell.getValue() as Date + return formatDate(dateVal) + } + + + // code etc... + return row.getValue(cfg.id) ?? "" + }, + } + + groupMap[groupName].push(childCol) + }) + + // ---------------------------------------------------------------- + // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기 + // ---------------------------------------------------------------- + const nestedColumns: ColumnDef<VendorContact>[] = [] + + // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함 + // 여기서는 그냥 Object.entries 순서 + Object.entries(groupMap).forEach(([groupName, colDefs]) => { + if (groupName === "_noGroup") { + // 그룹 없음 → 그냥 최상위 레벨 컬럼 + nestedColumns.push(...colDefs) + } else { + // 상위 컬럼 + nestedColumns.push({ + id: groupName, + header: groupName, // "Basic Info", "Metadata" 등 + columns: colDefs, + }) + } + }) + + // ---------------------------------------------------------------- + // 4) 최종 컬럼 배열: select, nestedColumns, actions + // ---------------------------------------------------------------- + return [ + selectColumn, + ...nestedColumns, + actionsColumn, + ] +}
\ No newline at end of file diff --git a/lib/vendors/contacts-table/contact-table-toolbar-actions.tsx b/lib/vendors/contacts-table/contact-table-toolbar-actions.tsx new file mode 100644 index 00000000..8aef6953 --- /dev/null +++ b/lib/vendors/contacts-table/contact-table-toolbar-actions.tsx @@ -0,0 +1,106 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Download, Upload } from "lucide-react" +import { toast } from "sonner" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" + + +// 만약 서버 액션이나 API 라우트를 이용해 업로드 처리한다면 import +import { importTasksExcel } from "@/lib/tasks/service" // 예시 +import { VendorContact } from "@/db/schema/vendors" +import { AddContactDialog } from "./add-contact-dialog" + +interface VendorsTableToolbarActionsProps { + table: Table<VendorContact> + vendorId: number +} + +export function VendorsTableToolbarActions({ table,vendorId }: VendorsTableToolbarActionsProps) { + // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식 + const fileInputRef = React.useRef<HTMLInputElement>(null) + + // 파일이 선택되었을 때 처리 + async function onFileChange(event: React.ChangeEvent<HTMLInputElement>) { + const file = event.target.files?.[0] + if (!file) return + + // 파일 초기화 (동일 파일 재업로드 시에도 onChange가 트리거되도록) + event.target.value = "" + + // 서버 액션 or API 호출 + try { + // 예: 서버 액션 호출 + const { errorFile, errorMessage } = await importTasksExcel(file) + + if (errorMessage) { + toast.error(errorMessage) + } + if (errorFile) { + // 에러 엑셀을 다운로드 + const url = URL.createObjectURL(errorFile) + const link = document.createElement("a") + link.href = url + link.download = "errors.xlsx" + link.click() + URL.revokeObjectURL(url) + } else { + // 성공 + toast.success("Import success") + // 필요 시 revalidateTag("tasks") 등 + } + + } catch (err) { + toast.error("파일 업로드 중 오류가 발생했습니다.") + + } + } + + function handleImportClick() { + // 숨겨진 <input type="file" /> 요소를 클릭 + fileInputRef.current?.click() + } + + return ( + <div className="flex items-center gap-2"> + + <AddContactDialog vendorId={vendorId}/> + + {/** 3) Import 버튼 (파일 업로드) */} + <Button variant="outline" size="sm" className="gap-2" onClick={handleImportClick}> + <Upload className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Import</span> + </Button> + {/* + 실제로는 숨겨진 input과 연결: + - accept=".xlsx,.xls" 등으로 Excel 파일만 업로드 허용 + */} + <input + ref={fileInputRef} + type="file" + accept=".xlsx,.xls" + className="hidden" + onChange={onFileChange} + /> + + {/** 4) Export 버튼 */} + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(table, { + filename: "tasks", + excludeColumns: ["select", "actions"], + }) + } + className="gap-2" + > + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Export</span> + </Button> + </div> + ) +}
\ No newline at end of file diff --git a/lib/vendors/contacts-table/contact-table.tsx b/lib/vendors/contacts-table/contact-table.tsx new file mode 100644 index 00000000..2991187e --- /dev/null +++ b/lib/vendors/contacts-table/contact-table.tsx @@ -0,0 +1,87 @@ +"use client" + +import * as React from "react" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" + +import { toSentenceCase } from "@/lib/utils" +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import { useFeatureFlags } from "./feature-flags-provider" +import { getColumns } from "./contact-table-columns" +import { getVendorContacts, } from "../service" +import { VendorContact, vendors } from "@/db/schema/vendors" +import { VendorsTableToolbarActions } from "./contact-table-toolbar-actions" + +interface VendorsTableProps { + promises: Promise< + [ + Awaited<ReturnType<typeof getVendorContacts>>, + ] + >, + vendorId:number +} + +export function VendorContactsTable({ promises , vendorId}: VendorsTableProps) { + const { featureFlags } = useFeatureFlags() + + // Suspense로 받아온 데이터 + const [{ data, pageCount }] = React.use(promises) + + const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorContact> | null>(null) + + // getColumns() 호출 시, router를 주입 + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + const filterFields: DataTableFilterField<VendorContact>[] = [ + + ] + + const advancedFilterFields: DataTableAdvancedFilterField<VendorContact>[] = [ + { id: "contactName", label: "Contact Name", type: "text" }, + { id: "contactPosition", label: "Contact Position", type: "text" }, + { id: "contactEmail", label: "Contact Email", type: "text" }, + { id: "contactPhone", label: "Contact Phone", type: "text" }, + { id: "createdAt", label: "Created at", type: "date" }, + { id: "updatedAt", label: "Updated at", type: "date" }, + ] + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + <DataTable + table={table} + > + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <VendorsTableToolbarActions table={table} vendorId={vendorId} /> + </DataTableAdvancedToolbar> + </DataTable> + </> + ) +}
\ No newline at end of file diff --git a/lib/vendors/contacts-table/feature-flags-provider.tsx b/lib/vendors/contacts-table/feature-flags-provider.tsx new file mode 100644 index 00000000..81131894 --- /dev/null +++ b/lib/vendors/contacts-table/feature-flags-provider.tsx @@ -0,0 +1,108 @@ +"use client" + +import * as React from "react" +import { useQueryState } from "nuqs" + +import { dataTableConfig, type DataTableConfig } from "@/config/data-table" +import { cn } from "@/lib/utils" +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" + +type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"] + +interface FeatureFlagsContextProps { + featureFlags: FeatureFlagValue[] + setFeatureFlags: (value: FeatureFlagValue[]) => void +} + +const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({ + featureFlags: [], + setFeatureFlags: () => {}, +}) + +export function useFeatureFlags() { + const context = React.useContext(FeatureFlagsContext) + if (!context) { + throw new Error( + "useFeatureFlags must be used within a FeatureFlagsProvider" + ) + } + return context +} + +interface FeatureFlagsProviderProps { + children: React.ReactNode +} + +export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) { + const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>( + "flags", + { + defaultValue: [], + parse: (value) => value.split(",") as FeatureFlagValue[], + serialize: (value) => value.join(","), + eq: (a, b) => + a.length === b.length && a.every((value, index) => value === b[index]), + clearOnDefault: true, + shallow: false, + } + ) + + return ( + <FeatureFlagsContext.Provider + value={{ + featureFlags, + setFeatureFlags: (value) => void setFeatureFlags(value), + }} + > + <div className="w-full overflow-x-auto"> + <ToggleGroup + type="multiple" + variant="outline" + size="sm" + value={featureFlags} + onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)} + className="w-fit gap-0" + > + {dataTableConfig.featureFlags.map((flag, index) => ( + <Tooltip key={flag.value}> + <ToggleGroupItem + value={flag.value} + className={cn( + "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90", + { + "rounded-l-sm border-r-0": index === 0, + "rounded-r-sm": + index === dataTableConfig.featureFlags.length - 1, + } + )} + asChild + > + <TooltipTrigger> + <flag.icon className="size-3.5 shrink-0" aria-hidden="true" /> + {flag.label} + </TooltipTrigger> + </ToggleGroupItem> + <TooltipContent + align="start" + side="bottom" + sideOffset={6} + className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground" + > + <div>{flag.tooltipTitle}</div> + <div className="text-xs text-muted-foreground"> + {flag.tooltipDescription} + </div> + </TooltipContent> + </Tooltip> + ))} + </ToggleGroup> + </div> + {children} + </FeatureFlagsContext.Provider> + ) +} diff --git a/lib/vendors/items-table/add-item-dialog.tsx b/lib/vendors/items-table/add-item-dialog.tsx new file mode 100644 index 00000000..6bbcc436 --- /dev/null +++ b/lib/vendors/items-table/add-item-dialog.tsx @@ -0,0 +1,289 @@ +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { Check, ChevronsUpDown } from "lucide-react" + +import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command" +import { cn } from "@/lib/utils" + +import { + createVendorItemSchema, + type CreateVendorItemSchema, +} from "../validations" + +import { createVendorItem, getItemsForVendor, ItemDropdownOption } from "../service" + +interface AddItemDialogProps { + vendorId: number +} + +export function AddItemDialog({ vendorId }: AddItemDialogProps) { + const [open, setOpen] = React.useState(false) + const [commandOpen, setCommandOpen] = React.useState(false) + const [items, setItems] = React.useState<ItemDropdownOption[]>([]) + const [filteredItems, setFilteredItems] = React.useState<ItemDropdownOption[]>([]) + const [isLoading, setIsLoading] = React.useState(false) + const [searchTerm, setSearchTerm] = React.useState("") + + // 선택된 아이템의 정보를 보여주기 위한 상태 + const [selectedItem, setSelectedItem] = React.useState<{ + itemName: string; + description: string; + } | null>(null) + + // react-hook-form 세팅 - 서버로 보낼 값은 vendorId와 itemCode만 + const form = useForm<CreateVendorItemSchema>({ + resolver: zodResolver(createVendorItemSchema), + defaultValues: { + vendorId, + itemCode: "", + }, + }) + + console.log(vendorId) + + // 아이템 목록 가져오기 (한 번만 호출) + const fetchItems = React.useCallback(async () => { + if (items.length > 0) return // 이미 로드된 경우 스킵 + + setIsLoading(true) + try { + const result = await getItemsForVendor(vendorId) + if (result.data) { + setItems(result.data) + setFilteredItems(result.data) + } + } catch (error) { + console.error("Failed to fetch items:", error) + } finally { + setIsLoading(false) + } + }, [items.length]) + + // 팝오버 열릴 때 아이템 목록 로드 + React.useEffect(() => { + if (commandOpen) { + fetchItems() + } + }, [commandOpen, fetchItems]) + + // 클라이언트 사이드 필터링 + React.useEffect(() => { + if (!items.length) return + + if (!searchTerm.trim()) { + setFilteredItems(items) + return + } + + const lowerSearch = searchTerm.toLowerCase() + const filtered = items.filter(item => + item.itemCode.toLowerCase().includes(lowerSearch) || + item.itemName.toLowerCase().includes(lowerSearch) || + (item.description && item.description.toLowerCase().includes(lowerSearch)) + ) + + setFilteredItems(filtered) + }, [searchTerm, items]) + + // 선택된 아이템 데이터로 폼 업데이트 + const handleSelectItem = (item: ItemDropdownOption) => { + // 폼에는 itemCode만 설정 + form.setValue("itemCode", item.itemCode) + + // 나머지 정보는 표시용 상태에 저장 + setSelectedItem({ + itemName: item.itemName, + description: item.description || "", + }) + + setCommandOpen(false) + } + + // 폼 제출 - itemCode만 서버로 전송 + async function onSubmit(data: CreateVendorItemSchema) { + // 서버에는 vendorId와 itemCode만 전송됨 + const result = await createVendorItem(data) + console.log(result) + if (result.error) { + alert(`에러: ${result.error}`) + return + } + // 성공 시 모달 닫고 폼 리셋 + form.reset() + setSelectedItem(null) + setOpen(false) + } + + // 모달 열림/닫힘 핸들 + function handleDialogOpenChange(nextOpen: boolean) { + if (!nextOpen) { + // 닫힐 때 폼 리셋 + form.reset() + setSelectedItem(null) + } + setOpen(nextOpen) + } + + // 현재 선택된 아이템 코드 + const selectedItemCode = form.watch("itemCode") + + // 선택된 아이템 코드가 있으면 상세 정보 표시를 위한 아이템 찾기 + const displayItemCode = selectedItemCode || "아이템 선택..." + const displayItemName = selectedItem?.itemName || "" + + return ( + <Dialog open={open} onOpenChange={handleDialogOpenChange}> + {/* 모달 열기 버튼 */} + <DialogTrigger asChild> + <Button variant="default" size="sm"> + Add Item + </Button> + </DialogTrigger> + + <DialogContent className="max-h-[90vh] overflow-hidden flex flex-col"> + <DialogHeader> + <DialogTitle>Create New Item</DialogTitle> + <DialogDescription> + 아이템을 선택한 후 <b>Create</b> 버튼을 누르세요. + </DialogDescription> + </DialogHeader> + + {/* shadcn/ui Form + react-hook-form */} + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col flex-1 overflow-hidden"> + <div className="space-y-4 py-4 flex-1 overflow-y-auto"> + + {/* 아이템 선택 */} + <div> + <FormLabel className="text-sm font-medium">아이템 선택</FormLabel> + <Popover open={commandOpen} onOpenChange={setCommandOpen}> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={commandOpen} + className="w-full justify-between mt-1" + > + {selectedItemCode + ? `${selectedItemCode} - ${displayItemName}` + : "아이템 선택..."} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-[400px] p-0"> + <Command> + <CommandInput + placeholder="아이템 코드/이름 검색..." + onValueChange={setSearchTerm} + /> + <CommandList className="max-h-[200px]"> + <CommandEmpty>검색 결과가 없습니다</CommandEmpty> + {isLoading ? ( + <div className="py-6 text-center text-sm">로딩 중...</div> + ) : ( + <CommandGroup> + {filteredItems.map((item) => ( + <CommandItem + key={item.itemCode} + value={`${item.itemCode} ${item.itemName}`} + onSelect={() => handleSelectItem(item)} + > + <Check + className={cn( + "mr-2 h-4 w-4", + selectedItemCode === item.itemCode + ? "opacity-100" + : "opacity-0" + )} + /> + <span className="font-medium">{item.itemCode}</span> + <span className="ml-2 text-gray-500 truncate">- {item.itemName}</span> + </CommandItem> + ))} + </CommandGroup> + )} + </CommandList> + </Command> + </PopoverContent> + </Popover> + </div> + + {/* 아이템 정보 영역 - 선택된 경우에만 표시 */} + {selectedItem && ( + <div className="rounded-md border p-3 mt-4 overflow-hidden"> + <h3 className="font-medium text-sm mb-2">선택된 아이템 정보</h3> + + {/* Item Code - readonly (hidden field) */} + <FormField + control={form.control} + name="itemCode" + render={({ field }) => ( + <FormItem className="hidden"> + <FormControl> + <Input {...field} /> + </FormControl> + </FormItem> + )} + /> + + {/* Item Name (표시용) */} + <div className="mb-2"> + <p className="text-xs font-medium text-gray-500">Item Name</p> + <p className="text-sm mt-0.5 break-words">{selectedItem.itemName}</p> + </div> + + {/* Description (표시용) */} + {selectedItem.description && ( + <div> + <p className="text-xs font-medium text-gray-500">Description</p> + <p className="text-sm mt-0.5 break-words max-h-20 overflow-y-auto">{selectedItem.description}</p> + </div> + )} + </div> + )} + + </div> + + <DialogFooter className="flex-shrink-0 pt-2"> + <Button type="button" variant="outline" onClick={() => setOpen(false)}> + Cancel + </Button> + <Button + type="submit" + disabled={form.formState.isSubmitting || !selectedItemCode} + > + Create + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/vendors/items-table/feature-flags-provider.tsx b/lib/vendors/items-table/feature-flags-provider.tsx new file mode 100644 index 00000000..81131894 --- /dev/null +++ b/lib/vendors/items-table/feature-flags-provider.tsx @@ -0,0 +1,108 @@ +"use client" + +import * as React from "react" +import { useQueryState } from "nuqs" + +import { dataTableConfig, type DataTableConfig } from "@/config/data-table" +import { cn } from "@/lib/utils" +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" + +type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"] + +interface FeatureFlagsContextProps { + featureFlags: FeatureFlagValue[] + setFeatureFlags: (value: FeatureFlagValue[]) => void +} + +const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({ + featureFlags: [], + setFeatureFlags: () => {}, +}) + +export function useFeatureFlags() { + const context = React.useContext(FeatureFlagsContext) + if (!context) { + throw new Error( + "useFeatureFlags must be used within a FeatureFlagsProvider" + ) + } + return context +} + +interface FeatureFlagsProviderProps { + children: React.ReactNode +} + +export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) { + const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>( + "flags", + { + defaultValue: [], + parse: (value) => value.split(",") as FeatureFlagValue[], + serialize: (value) => value.join(","), + eq: (a, b) => + a.length === b.length && a.every((value, index) => value === b[index]), + clearOnDefault: true, + shallow: false, + } + ) + + return ( + <FeatureFlagsContext.Provider + value={{ + featureFlags, + setFeatureFlags: (value) => void setFeatureFlags(value), + }} + > + <div className="w-full overflow-x-auto"> + <ToggleGroup + type="multiple" + variant="outline" + size="sm" + value={featureFlags} + onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)} + className="w-fit gap-0" + > + {dataTableConfig.featureFlags.map((flag, index) => ( + <Tooltip key={flag.value}> + <ToggleGroupItem + value={flag.value} + className={cn( + "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90", + { + "rounded-l-sm border-r-0": index === 0, + "rounded-r-sm": + index === dataTableConfig.featureFlags.length - 1, + } + )} + asChild + > + <TooltipTrigger> + <flag.icon className="size-3.5 shrink-0" aria-hidden="true" /> + {flag.label} + </TooltipTrigger> + </ToggleGroupItem> + <TooltipContent + align="start" + side="bottom" + sideOffset={6} + className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground" + > + <div>{flag.tooltipTitle}</div> + <div className="text-xs text-muted-foreground"> + {flag.tooltipDescription} + </div> + </TooltipContent> + </Tooltip> + ))} + </ToggleGroup> + </div> + {children} + </FeatureFlagsContext.Provider> + ) +} diff --git a/lib/vendors/items-table/item-table-columns.tsx b/lib/vendors/items-table/item-table-columns.tsx new file mode 100644 index 00000000..b5d26434 --- /dev/null +++ b/lib/vendors/items-table/item-table-columns.tsx @@ -0,0 +1,197 @@ +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { Ellipsis } from "lucide-react" +import { toast } from "sonner" + +import { getErrorMessage } from "@/lib/handle-error" +import { formatDate } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header" + +import { VendorItemsView, vendors } from "@/db/schema/vendors" +import { modifyVendor } from "../service" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { vendorItemsColumnsConfig } from "@/config/vendorItemsColumnsConfig" + + + + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<VendorItemsView> | null>>; +} + +/** + * tanstack table 컬럼 정의 (중첩 헤더 버전) + */ +export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<VendorItemsView>[] { + // ---------------------------------------------------------------- + // 1) select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef<VendorItemsView> = { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + size:40, + enableSorting: false, + enableHiding: false, + } + + // ---------------------------------------------------------------- + // 2) actions 컬럼 (Dropdown 메뉴) + // ---------------------------------------------------------------- + const actionsColumn: ColumnDef<VendorItemsView> = { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-8 p-0 data-[state=open]:bg-muted" + > + <Ellipsis className="size-4" aria-hidden="true" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-40"> + <DropdownMenuItem + onSelect={() => { + setRowAction({ row, type: "update" }) + + }} + > + Edit + </DropdownMenuItem> + + <DropdownMenuSeparator /> + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "delete" })} + > + Delete + <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ) + }, + size: 40, + } + + // ---------------------------------------------------------------- + // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 + // ---------------------------------------------------------------- + // 3-1) groupMap: { [groupName]: ColumnDef<VendorItemsView>[] } + const groupMap: Record<string, ColumnDef<VendorItemsView>[]> = {} + + vendorItemsColumnsConfig.forEach((cfg) => { + // 만약 group가 없으면 "_noGroup" 처리 + const groupName = cfg.group || "_noGroup" + + if (!groupMap[groupName]) { + groupMap[groupName] = [] + } + + // child column 정의 + const childCol: ColumnDef<VendorItemsView> = { + accessorKey: cfg.id, + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title={cfg.label} /> + ), + minSize: cfg.minWidth, + size: cfg.defaultWidth, + meta: { + excelHeader: cfg.excelHeader, + group: cfg.group, + type: cfg.type, + }, + cell: ({ row, cell }) => { + + + if (cfg.id === "createdAt") { + const dateVal = cell.getValue() as Date + return formatDate(dateVal) + } + + if (cfg.id === "updatedAt") { + const dateVal = cell.getValue() as Date + return formatDate(dateVal) + } + + + // code etc... + return row.getValue(cfg.id) ?? "" + }, + } + + groupMap[groupName].push(childCol) + }) + + // ---------------------------------------------------------------- + // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기 + // ---------------------------------------------------------------- + const nestedColumns: ColumnDef<VendorItemsView>[] = [] + + // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함 + // 여기서는 그냥 Object.entries 순서 + Object.entries(groupMap).forEach(([groupName, colDefs]) => { + if (groupName === "_noGroup") { + // 그룹 없음 → 그냥 최상위 레벨 컬럼 + nestedColumns.push(...colDefs) + } else { + // 상위 컬럼 + nestedColumns.push({ + id: groupName, + header: groupName, // "Basic Info", "Metadata" 등 + columns: colDefs, + }) + } + }) + + // ---------------------------------------------------------------- + // 4) 최종 컬럼 배열: select, nestedColumns, actions + // ---------------------------------------------------------------- + return [ + selectColumn, + ...nestedColumns, + actionsColumn, + ] +}
\ No newline at end of file diff --git a/lib/vendors/items-table/item-table-toolbar-actions.tsx b/lib/vendors/items-table/item-table-toolbar-actions.tsx new file mode 100644 index 00000000..f7bd2bf6 --- /dev/null +++ b/lib/vendors/items-table/item-table-toolbar-actions.tsx @@ -0,0 +1,106 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Download, Upload } from "lucide-react" +import { toast } from "sonner" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" + + +// 만약 서버 액션이나 API 라우트를 이용해 업로드 처리한다면 import +import { importTasksExcel } from "@/lib/tasks/service" // 예시 +import { VendorItemsView } from "@/db/schema/vendors" +import { AddItemDialog } from "./add-item-dialog" + +interface VendorsTableToolbarActionsProps { + table: Table<VendorItemsView> + vendorId: number +} + +export function VendorsTableToolbarActions({ table,vendorId }: VendorsTableToolbarActionsProps) { + // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식 + const fileInputRef = React.useRef<HTMLInputElement>(null) + + // 파일이 선택되었을 때 처리 + async function onFileChange(event: React.ChangeEvent<HTMLInputElement>) { + const file = event.target.files?.[0] + if (!file) return + + // 파일 초기화 (동일 파일 재업로드 시에도 onChange가 트리거되도록) + event.target.value = "" + + // 서버 액션 or API 호출 + try { + // 예: 서버 액션 호출 + const { errorFile, errorMessage } = await importTasksExcel(file) + + if (errorMessage) { + toast.error(errorMessage) + } + if (errorFile) { + // 에러 엑셀을 다운로드 + const url = URL.createObjectURL(errorFile) + const link = document.createElement("a") + link.href = url + link.download = "errors.xlsx" + link.click() + URL.revokeObjectURL(url) + } else { + // 성공 + toast.success("Import success") + // 필요 시 revalidateTag("tasks") 등 + } + + } catch (err) { + toast.error("파일 업로드 중 오류가 발생했습니다.") + + } + } + + function handleImportClick() { + // 숨겨진 <input type="file" /> 요소를 클릭 + fileInputRef.current?.click() + } + + return ( + <div className="flex items-center gap-2"> + + <AddItemDialog vendorId={vendorId}/> + + {/** 3) Import 버튼 (파일 업로드) */} + <Button variant="outline" size="sm" className="gap-2" onClick={handleImportClick}> + <Upload className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Import</span> + </Button> + {/* + 실제로는 숨겨진 input과 연결: + - accept=".xlsx,.xls" 등으로 Excel 파일만 업로드 허용 + */} + <input + ref={fileInputRef} + type="file" + accept=".xlsx,.xls" + className="hidden" + onChange={onFileChange} + /> + + {/** 4) Export 버튼 */} + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(table, { + filename: "tasks", + excludeColumns: ["select", "actions"], + }) + } + className="gap-2" + > + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Export</span> + </Button> + </div> + ) +}
\ No newline at end of file diff --git a/lib/vendors/items-table/item-table.tsx b/lib/vendors/items-table/item-table.tsx new file mode 100644 index 00000000..d8cd0ea2 --- /dev/null +++ b/lib/vendors/items-table/item-table.tsx @@ -0,0 +1,85 @@ +"use client" + +import * as React from "react" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" + +import { toSentenceCase } from "@/lib/utils" +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import { useFeatureFlags } from "./feature-flags-provider" +import { getColumns } from "./item-table-columns" +import { getVendorItems, } from "../service" +import { VendorItemsView, vendors } from "@/db/schema/vendors" +import { VendorsTableToolbarActions } from "./item-table-toolbar-actions" + +interface VendorsTableProps { + promises: Promise< + [ + Awaited<ReturnType<typeof getVendorItems>>, + ] + >, + vendorId:number +} + +export function VendorItemsTable({ promises , vendorId}: VendorsTableProps) { + const { featureFlags } = useFeatureFlags() + + // Suspense로 받아온 데이터 + const [{ data, pageCount }] = React.use(promises) + const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorItemsView> | null>(null) + + // getColumns() 호출 시, router를 주입 + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + const filterFields: DataTableFilterField<VendorItemsView>[] = [ + + ] + + const advancedFilterFields: DataTableAdvancedFilterField<VendorItemsView>[] = [ + { id: "itemName", label: "Item Name", type: "text" }, + { id: "itemCode", label: "Item Code", type: "text" }, + { id: "description", label: "Description", type: "text" }, + { id: "createdAt", label: "Created at", type: "date" }, + { id: "updatedAt", label: "Updated at", type: "date" }, + ] + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.itemCode), + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + <DataTable + table={table} + > + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <VendorsTableToolbarActions table={table} vendorId={vendorId} /> + </DataTableAdvancedToolbar> + </DataTable> + </> + ) +}
\ No newline at end of file diff --git a/lib/vendors/repository.ts b/lib/vendors/repository.ts new file mode 100644 index 00000000..ff195932 --- /dev/null +++ b/lib/vendors/repository.ts @@ -0,0 +1,282 @@ +// src/lib/vendors/repository.ts + +import { and, eq, inArray, count, gt, AnyColumn, SQLWrapper, SQL} from "drizzle-orm"; +import { PgTransaction } from "drizzle-orm/pg-core"; +import { VendorContact, vendorContacts, vendorItemsView, vendorPossibleItems, vendors, type Vendor } from "@/db/schema/vendors"; +import db from '@/db/db'; +import { items } from "@/db/schema/items"; +import { rfqs,rfqItems, rfqEvaluations, vendorResponses } from "@/db/schema/rfq"; +import { sql } from "drizzle-orm"; + +interface SelectVendorsOptions { + where?: any; + orderBy?: any[]; + offset?: number; + limit?: number; +} +export declare function asc(column: AnyColumn | SQLWrapper): SQL; +export declare function desc(column: AnyColumn | SQLWrapper): SQL; +export type NewVendorContact = typeof vendorContacts.$inferInsert +export type NewVendorItem = typeof vendorPossibleItems.$inferInsert + +/** + * 1) SELECT (목록 조회) + */ +export async function selectVendors( + tx: PgTransaction<any, any, any>, + { where, orderBy, offset, limit }: SelectVendorsOptions +) { + return tx + .select() + .from(vendors) + .where(where ?? undefined) + .orderBy(...(orderBy ?? [])) + .offset(offset ?? 0) + .limit(limit ?? 20); +} + +/** + * 2) COUNT + */ +export async function countVendors( + tx: PgTransaction<any, any, any>, + where?: any + ) { + const res = await tx.select({ count: count() }).from(vendors).where(where); + return res[0]?.count ?? 0; + } + + +/** + * 3) INSERT (단일 벤더 생성) + * - id/createdAt/updatedAt은 DB default 사용 + * - 반환값은 "생성된 레코드" 배열 ([newVendor]) + */ +export async function insertVendor( + tx: PgTransaction<any, any, any>, + data: Omit<Vendor, "id" | "createdAt" | "updatedAt"> +) { + return tx.insert(vendors).values(data).returning(); +} + +/** + * 4) UPDATE (단일 벤더) + */ +export async function updateVendor( + tx: PgTransaction<any, any, any>, + id: string, + data: Partial<Vendor> +) { + return tx + .update(vendors) + .set(data) + .where(eq(vendors.id, Number(id))) + .returning(); +} + +/** + * 5) UPDATE (복수 벤더) + * - 여러 개의 id를 받아 일괄 업데이트 + */ +export async function updateVendors( + tx: PgTransaction<any, any, any>, + ids: string[], + data: Partial<Vendor> +) { + const numericIds = ids.map((i) => Number(i)); + return tx + .update(vendors) + .set(data) + .where(inArray(vendors.id, numericIds)) + .returning(); +} + +/** status 기준 groupBy */ +export async function groupByStatus( + tx: PgTransaction<any, any, any>, +) { + return tx + .select({ + status: vendors.status, + count: count(), + }) + .from(vendors) + .groupBy(vendors.status) + .having(gt(count(), 0)); +} + + +// ID로 사용자 조회 +export const getVendorById = async (id: number): Promise<Vendor | null> => { + const vendorsRes = await db.select().from(vendors).where(eq(vendors.id, id)).execute(); + if (vendorsRes.length === 0) return null; + + const vendor = vendorsRes[0]; + return vendor +}; + +export const getVendorContactsById = async (id: number): Promise<VendorContact | null> => { + const contactsRes = await db.select().from(vendorContacts).where(eq(vendorContacts.vendorId, id)).execute(); + if (contactsRes.length === 0) return null; + + const contact = contactsRes[0]; + return contact +}; + +export async function selectVendorContacts( + tx: PgTransaction<any, any, any>, + params: { + where?: any; // drizzle-orm의 조건식 (and, eq...) 등 + orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[]; + offset?: number; + limit?: number; + } +) { + const { where, orderBy, offset = 0, limit = 10 } = params; + + return tx + .select() + .from(vendorContacts) + .where(where) + .orderBy(...(orderBy ?? [])) + .offset(offset) + .limit(limit); +} + +export async function countVendorContacts( + tx: PgTransaction<any, any, any>, + where?: any +) { + const res = await tx.select({ count: count() }).from(vendorContacts).where(where); + return res[0]?.count ?? 0; +} + +export async function insertVendorContact( + tx: PgTransaction<any, any, any>, + data: NewVendorContact // DB와 동일한 insert 가능한 타입 +) { + // returning() 사용 시 배열로 돌아오므로 [0]만 리턴 + return tx + .insert(vendorContacts) + .values(data) + .returning({ id: vendorContacts.id, createdAt: vendorContacts.createdAt }); +} + + +export async function selectVendorItems( + tx: PgTransaction<any, any, any>, + params: { + where?: any; // drizzle-orm의 조건식 (and, eq...) 등 + orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[]; + offset?: number; + limit?: number; + } +) { + const { where, orderBy, offset = 0, limit = 10 } = params; + + return tx + .select({ + // vendor_possible_items cols + vendorItemId: vendorItemsView.vendorItemId, + vendorId: vendorItemsView.vendorId, + itemCode: vendorItemsView.itemCode, + createdAt: vendorItemsView.createdAt, + updatedAt: vendorItemsView.updatedAt, + itemName: vendorItemsView.itemName, + description: vendorItemsView.description, + }) + .from(vendorItemsView) + .where(where ?? undefined) + .orderBy(...(orderBy ?? [])) + .offset(offset) + .limit(limit); +} + +export async function countVendorItems( + tx: PgTransaction<any, any, any>, + where?: any +) { + const res = await tx.select({ count: count() }).from(vendorItemsView).where(where); + return res[0]?.count ?? 0; +} + +export async function insertVendorItem( + tx: PgTransaction<any, any, any>, + data: NewVendorItem // DB와 동일한 insert 가능한 타입 +) { + // returning() 사용 시 배열로 돌아오므로 [0]만 리턴 + return tx + .insert(vendorPossibleItems) + .values(data) + .returning({ id: vendorPossibleItems.id, createdAt: vendorPossibleItems.createdAt }); +} + +export async function selectRfqHistory( + tx: PgTransaction<any, any, any>, + { where, orderBy, offset, limit }: SelectVendorsOptions +) { + return tx + .select({ + // RFQ 기본 정보 + id: rfqs.id, + rfqCode: rfqs.rfqCode, + + description: rfqs.description, + dueDate: rfqs.dueDate, + status: rfqs.status, + createdAt: rfqs.createdAt, + + + // Item 정보 (집계) + itemCount: sql<number>`count(distinct ${rfqItems.id})::integer`, + + // 평가 정보 + tbeResult: sql<string>` + (select result from ${rfqEvaluations} + where rfq_id = ${rfqs.id} + and vendor_id = ${vendorResponses.vendorId} + and eval_type = 'TBE' + limit 1)`, + cbeResult: sql<string>` + (select result from ${rfqEvaluations} + where rfq_id = ${rfqs.id} + and vendor_id = ${vendorResponses.vendorId} + and eval_type = 'CBE' + limit 1)` + }) + .from(rfqs) + .innerJoin(vendorResponses, eq(rfqs.id, vendorResponses.rfqId)) + + .leftJoin(rfqItems, eq(rfqs.id, rfqItems.rfqId)) + .where(where ?? undefined) + .groupBy( + rfqs.id, + rfqs.rfqCode, + + rfqs.description, + rfqs.dueDate, + rfqs.status, + rfqs.createdAt, + + vendorResponses.vendorId, + + ) + .orderBy(...(orderBy ?? [])) + .offset(offset ?? 0) + .limit(limit ?? 20); +} + +export async function countRfqHistory( + tx: PgTransaction<any, any, any>, + where?: any +) { + const [{ count }] = await tx + .select({ + count: sql<number>`count(distinct ${rfqs.id})::integer`, + }) + .from(rfqs) + .innerJoin(vendorResponses, eq(rfqs.id, vendorResponses.rfqId)) + .where(where ?? undefined); + + return count; +} diff --git a/lib/vendors/rfq-history-table/feature-flags-provider.tsx b/lib/vendors/rfq-history-table/feature-flags-provider.tsx new file mode 100644 index 00000000..81131894 --- /dev/null +++ b/lib/vendors/rfq-history-table/feature-flags-provider.tsx @@ -0,0 +1,108 @@ +"use client" + +import * as React from "react" +import { useQueryState } from "nuqs" + +import { dataTableConfig, type DataTableConfig } from "@/config/data-table" +import { cn } from "@/lib/utils" +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" + +type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"] + +interface FeatureFlagsContextProps { + featureFlags: FeatureFlagValue[] + setFeatureFlags: (value: FeatureFlagValue[]) => void +} + +const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({ + featureFlags: [], + setFeatureFlags: () => {}, +}) + +export function useFeatureFlags() { + const context = React.useContext(FeatureFlagsContext) + if (!context) { + throw new Error( + "useFeatureFlags must be used within a FeatureFlagsProvider" + ) + } + return context +} + +interface FeatureFlagsProviderProps { + children: React.ReactNode +} + +export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) { + const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>( + "flags", + { + defaultValue: [], + parse: (value) => value.split(",") as FeatureFlagValue[], + serialize: (value) => value.join(","), + eq: (a, b) => + a.length === b.length && a.every((value, index) => value === b[index]), + clearOnDefault: true, + shallow: false, + } + ) + + return ( + <FeatureFlagsContext.Provider + value={{ + featureFlags, + setFeatureFlags: (value) => void setFeatureFlags(value), + }} + > + <div className="w-full overflow-x-auto"> + <ToggleGroup + type="multiple" + variant="outline" + size="sm" + value={featureFlags} + onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)} + className="w-fit gap-0" + > + {dataTableConfig.featureFlags.map((flag, index) => ( + <Tooltip key={flag.value}> + <ToggleGroupItem + value={flag.value} + className={cn( + "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90", + { + "rounded-l-sm border-r-0": index === 0, + "rounded-r-sm": + index === dataTableConfig.featureFlags.length - 1, + } + )} + asChild + > + <TooltipTrigger> + <flag.icon className="size-3.5 shrink-0" aria-hidden="true" /> + {flag.label} + </TooltipTrigger> + </ToggleGroupItem> + <TooltipContent + align="start" + side="bottom" + sideOffset={6} + className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground" + > + <div>{flag.tooltipTitle}</div> + <div className="text-xs text-muted-foreground"> + {flag.tooltipDescription} + </div> + </TooltipContent> + </Tooltip> + ))} + </ToggleGroup> + </div> + {children} + </FeatureFlagsContext.Provider> + ) +} diff --git a/lib/vendors/rfq-history-table/rfq-history-table-columns.tsx b/lib/vendors/rfq-history-table/rfq-history-table-columns.tsx new file mode 100644 index 00000000..7e22e96a --- /dev/null +++ b/lib/vendors/rfq-history-table/rfq-history-table-columns.tsx @@ -0,0 +1,223 @@ +"use client"
+
+import * as React from "react"
+import { type DataTableRowAction } from "@/types/table"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Ellipsis } from "lucide-react"
+import { toast } from "sonner"
+
+import { getErrorMessage } from "@/lib/handle-error"
+import { formatDate } from "@/lib/utils"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header"
+
+import { VendorItem, vendors } from "@/db/schema/vendors"
+import { modifyVendor } from "../service"
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { getRFQStatusIcon } from "@/lib/tasks/utils"
+import { rfqHistoryColumnsConfig } from "@/config/rfqHistoryColumnsConfig"
+
+export interface RfqHistoryRow {
+ id: number;
+ rfqCode: string | null;
+ projectCode: string | null;
+ projectName: string | null;
+ description: string | null;
+ dueDate: Date;
+ status: "DRAFT" | "PUBLISHED" | "EVALUATION" | "AWARDED";
+ vendorStatus: string;
+ totalAmount: number | null;
+ currency: string | null;
+ leadTime: string | null;
+ itemCount: number;
+ tbeResult: string | null;
+ cbeResult: string | null;
+ createdAt: Date;
+ items: {
+ rfqId: number;
+ id: number;
+ itemCode: string;
+ description: string | null;
+ quantity: number | null;
+ uom: string | null;
+ }[];
+}
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<RfqHistoryRow> | null>>;
+ openItemsModal: (rfqId: number) => void;
+}
+
+/**
+ * tanstack table 컬럼 정의 (중첩 헤더 버전)
+ */
+export function getColumns({ setRowAction, openItemsModal }: GetColumnsProps): ColumnDef<RfqHistoryRow>[] {
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<RfqHistoryRow> = {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ className="translate-y-0.5"
+ />
+ ),
+ size: 40,
+ enableSorting: false,
+ enableHiding: false,
+ }
+
+ // ----------------------------------------------------------------
+ // 2) actions 컬럼 (Dropdown 메뉴)
+ // ----------------------------------------------------------------
+ const actionsColumn: ColumnDef<RfqHistoryRow> = {
+ id: "actions",
+ enableHiding: false,
+ cell: function Cell({ row }) {
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ aria-label="Open menu"
+ variant="ghost"
+ className="flex size-8 p-0 data-[state=open]:bg-muted"
+ >
+ <Ellipsis className="size-4" aria-hidden="true" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-40">
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "update" })}
+ >
+ View Details
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ size: 40,
+ }
+
+ // ----------------------------------------------------------------
+ // 3) 일반 컬럼들
+ // ----------------------------------------------------------------
+ const basicColumns: ColumnDef<RfqHistoryRow>[] = rfqHistoryColumnsConfig.map((cfg) => {
+ const column: ColumnDef<RfqHistoryRow> = {
+ accessorKey: cfg.id,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title={cfg.label} />
+ ),
+ size: cfg.size,
+ }
+
+ if (cfg.id === "description") {
+ column.cell = ({ row }) => {
+ const description = row.original.description
+ if (!description) return null
+ return (
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <div className="break-words whitespace-normal line-clamp-2">
+ {description}
+ </div>
+ </TooltipTrigger>
+ <TooltipContent side="bottom" className="max-w-[400px] whitespace-pre-wrap break-words">
+ {description}
+ </TooltipContent>
+ </Tooltip>
+ )
+ }
+ }
+
+ if (cfg.id === "status") {
+ column.cell = ({ row }) => {
+ const statusVal = row.original.status
+ if (!statusVal) return null
+ const Icon = getRFQStatusIcon(statusVal)
+ return (
+ <div className="flex items-center">
+ <Icon className="mr-2 size-4 text-muted-foreground" aria-hidden="true" />
+ <span className="capitalize">{statusVal}</span>
+ </div>
+ )
+ }
+ }
+
+ if (cfg.id === "totalAmount") {
+ column.cell = ({ row }) => {
+ const amount = row.original.totalAmount
+ const currency = row.original.currency
+ if (!amount || !currency) return null
+ return (
+ <div className="whitespace-nowrap">
+ {`${currency} ${amount.toLocaleString()}`}
+ </div>
+ )
+ }
+ }
+
+ if (cfg.id === "dueDate" || cfg.id === "createdAt") {
+ column.cell = ({ row }) => (
+ <div className="whitespace-nowrap">
+ {formatDate(row.getValue(cfg.id))}
+ </div>
+ )
+ }
+
+ return column
+ })
+
+ const itemsColumn: ColumnDef<RfqHistoryRow> = {
+ id: "items",
+ header: "Items",
+ cell: ({ row }) => {
+ const rfq = row.original;
+ const count = rfq.itemCount || 0;
+ return (
+ <Button variant="ghost" onClick={() => openItemsModal(rfq.id)}>
+ {count === 0 ? "No Items" : `${count} Items`}
+ </Button>
+ )
+ },
+ }
+
+ // ----------------------------------------------------------------
+ // 4) 최종 컬럼 배열
+ // ----------------------------------------------------------------
+ return [
+ selectColumn,
+ ...basicColumns,
+ itemsColumn,
+ actionsColumn,
+ ]
+}
\ No newline at end of file diff --git a/lib/vendors/rfq-history-table/rfq-history-table-toolbar-actions.tsx b/lib/vendors/rfq-history-table/rfq-history-table-toolbar-actions.tsx new file mode 100644 index 00000000..46eaa6a6 --- /dev/null +++ b/lib/vendors/rfq-history-table/rfq-history-table-toolbar-actions.tsx @@ -0,0 +1,136 @@ +"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { Download, Upload } from "lucide-react"
+import { toast } from "sonner"
+
+import { exportTableToExcel } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+import { DataTableViewOptions } from "@/components/data-table/data-table-view-options"
+
+
+// 만약 서버 액션이나 API 라우트를 이용해 업로드 처리한다면 import
+import { importTasksExcel } from "@/lib/tasks/service" // 예시
+import { VendorItem } from "@/db/schema/vendors"
+// import { AddItemDialog } from "./add-item-dialog"
+
+interface RfqHistoryRow {
+ id: number;
+ rfqCode: string | null;
+ projectCode: string | null;
+ projectName: string | null;
+ description: string | null;
+ dueDate: Date;
+ status: "DRAFT" | "PUBLISHED" | "EVALUATION" | "AWARDED";
+ vendorStatus: string;
+ totalAmount: number | null;
+ currency: string | null;
+ leadTime: string | null;
+ itemCount: number;
+ tbeResult: string | null;
+ cbeResult: string | null;
+ createdAt: Date;
+ items: {
+ rfqId: number;
+ id: number;
+ itemCode: string;
+ description: string | null;
+ quantity: number | null;
+ uom: string | null;
+ }[];
+}
+
+interface RfqHistoryTableToolbarActionsProps {
+ table: Table<RfqHistoryRow>
+}
+
+export function RfqHistoryTableToolbarActions({
+ table,
+}: RfqHistoryTableToolbarActionsProps) {
+ // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식
+ const fileInputRef = React.useRef<HTMLInputElement>(null)
+
+ // 파일이 선택되었을 때 처리
+ async function onFileChange(event: React.ChangeEvent<HTMLInputElement>) {
+ const file = event.target.files?.[0]
+ if (!file) return
+
+ // 파일 초기화 (동일 파일 재업로드 시에도 onChange가 트리거되도록)
+ event.target.value = ""
+
+ // 서버 액션 or API 호출
+ try {
+ // 예: 서버 액션 호출
+ const { errorFile, errorMessage } = await importTasksExcel(file)
+
+ if (errorMessage) {
+ toast.error(errorMessage)
+ }
+ if (errorFile) {
+ // 에러 엑셀을 다운로드
+ const url = URL.createObjectURL(errorFile)
+ const link = document.createElement("a")
+ link.href = url
+ link.download = "errors.xlsx"
+ link.click()
+ URL.revokeObjectURL(url)
+ } else {
+ // 성공
+ toast.success("Import success")
+ // 필요 시 revalidateTag("tasks") 등
+ }
+
+ } catch (err) {
+ toast.error("파일 업로드 중 오류가 발생했습니다.")
+
+ }
+ }
+
+ // function handleImportClick() {
+ // // 숨겨진 <input type="file" /> 요소를 클릭
+ // fileInputRef.current?.click()
+ // }
+
+ return (
+ <div className="flex items-center gap-2">
+ <DataTableViewOptions table={table} />
+
+ {/* 조회만 하는 모듈 */}
+ {/* <AddItemDialog vendorId={vendorId}/> */}
+
+ {/** 3) Import 버튼 (파일 업로드) */}
+ {/* <Button variant="outline" size="sm" className="gap-2" onClick={handleImportClick}>
+ <Upload className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Import</span>
+ </Button> */}
+ {/*
+ 실제로는 숨겨진 input과 연결:
+ - accept=".xlsx,.xls" 등으로 Excel 파일만 업로드 허용
+ */}
+ <input
+ ref={fileInputRef}
+ type="file"
+ accept=".xlsx,.xls"
+ className="hidden"
+ onChange={onFileChange}
+ />
+
+ {/** 4) Export 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() =>
+ exportTableToExcel(table, {
+ filename: "rfq-history",
+ excludeColumns: ["select", "actions"],
+ })
+ }
+ className="gap-2"
+ >
+ <Download className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Export</span>
+ </Button>
+ </div>
+ )
+}
\ No newline at end of file diff --git a/lib/vendors/rfq-history-table/rfq-history-table.tsx b/lib/vendors/rfq-history-table/rfq-history-table.tsx new file mode 100644 index 00000000..71830303 --- /dev/null +++ b/lib/vendors/rfq-history-table/rfq-history-table.tsx @@ -0,0 +1,156 @@ +"use client" + +import * as React from "react" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" + +import { toSentenceCase } from "@/lib/utils" +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import { getColumns } from "./rfq-history-table-columns" +import { getRfqHistory } from "../service" +import { RfqHistoryTableToolbarActions } from "./rfq-history-table-toolbar-actions" +import { RfqItemsTableDialog } from "./rfq-items-table-dialog" +import { getRFQStatusIcon } from "@/lib/tasks/utils" +import { TooltipProvider } from "@/components/ui/tooltip" + +export interface RfqHistoryRow { + id: number; + rfqCode: string | null; + projectCode: string | null; + projectName: string | null; + description: string | null; + dueDate: Date; + status: "DRAFT" | "PUBLISHED" | "EVALUATION" | "AWARDED"; + vendorStatus: string; + totalAmount: number | null; + currency: string | null; + leadTime: string | null; + itemCount: number; + tbeResult: string | null; + cbeResult: string | null; + createdAt: Date; + items: { + rfqId: number; + id: number; + itemCode: string; + description: string | null; + quantity: number | null; + uom: string | null; + }[]; +} + +interface RfqHistoryTableProps { + promises: Promise< + [ + Awaited<ReturnType<typeof getRfqHistory>>, + ] + > +} + +export function VendorRfqHistoryTable({ promises }: RfqHistoryTableProps) { + const [{ data, pageCount }] = React.use(promises) + + const [rowAction, setRowAction] = React.useState<DataTableRowAction<RfqHistoryRow> | null>(null) + + const [itemsModalOpen, setItemsModalOpen] = React.useState(false); + const [selectedRfq, setSelectedRfq] = React.useState<RfqHistoryRow | null>(null); + + const openItemsModal = React.useCallback((rfqId: number) => { + const rfq = data.find(r => r.id === rfqId); + if (rfq) { + setSelectedRfq(rfq); + setItemsModalOpen(true); + } + }, [data]); + + const columns = React.useMemo(() => getColumns({ + setRowAction, + openItemsModal, + }), [setRowAction, openItemsModal]); + + const filterFields: DataTableFilterField<RfqHistoryRow>[] = [ + { + id: "rfqCode", + label: "RFQ Code", + placeholder: "Filter RFQ Code...", + }, + { + id: "status", + label: "Status", + options: ["DRAFT", "PUBLISHED", "EVALUATION", "AWARDED"].map((status) => ({ + label: toSentenceCase(status), + value: status, + icon: getRFQStatusIcon(status), + })), + }, + { + id: "vendorStatus", + label: "Vendor Status", + placeholder: "Filter Vendor Status...", + } + ] + + const advancedFilterFields: DataTableAdvancedFilterField<RfqHistoryRow>[] = [ + { id: "rfqCode", label: "RFQ Code", type: "text" }, + { id: "projectCode", label: "Project Code", type: "text" }, + { id: "projectName", label: "Project Name", type: "text" }, + { + id: "status", + label: "RFQ Status", + type: "multi-select", + options: ["DRAFT", "PUBLISHED", "EVALUATION", "AWARDED"].map((status) => ({ + label: toSentenceCase(status), + value: status, + icon: getRFQStatusIcon(status), + })), + }, + { id: "vendorStatus", label: "Vendor Status", type: "text" }, + { id: "dueDate", label: "Due Date", type: "date" }, + { id: "createdAt", label: "Created At", type: "date" }, + ] + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: true, + clearOnDefault: true, + }) + + return ( + <> + <TooltipProvider> + <DataTable + table={table} + > + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <RfqHistoryTableToolbarActions table={table} /> + </DataTableAdvancedToolbar> + </DataTable> + + <RfqItemsTableDialog + open={itemsModalOpen} + onOpenChange={setItemsModalOpen} + items={selectedRfq?.items ?? []} + /> + </TooltipProvider> + </> + ) +}
\ No newline at end of file diff --git a/lib/vendors/rfq-history-table/rfq-items-table-dialog.tsx b/lib/vendors/rfq-history-table/rfq-items-table-dialog.tsx new file mode 100644 index 00000000..49a5d890 --- /dev/null +++ b/lib/vendors/rfq-history-table/rfq-items-table-dialog.tsx @@ -0,0 +1,98 @@ +"use client" + +import * as React from "react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { DataTable } from "@/components/data-table/data-table" +import { useDataTable } from "@/hooks/use-data-table" +import { type ColumnDef } from "@tanstack/react-table" +import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header" + +interface RfqItem { + id: number + itemCode: string + description: string | null + quantity: number | null + uom: string | null +} + +interface RfqItemsTableDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + items: RfqItem[] +} + +export function RfqItemsTableDialog({ + open, + onOpenChange, + items, +}: RfqItemsTableDialogProps) { + const columns = React.useMemo<ColumnDef<RfqItem>[]>( + () => [ + { + accessorKey: "itemCode", + header: ({ column }) => ( + <DataTableColumnHeader column={column} title="Item Code" /> + ), + }, + { + accessorKey: "description", + header: ({ column }) => ( + <DataTableColumnHeader column={column} title="Description" /> + ), + cell: ({ row }) => row.getValue("description") || "-", + }, + { + accessorKey: "quantity", + header: ({ column }) => ( + <DataTableColumnHeader column={column} title="Quantity" /> + ), + cell: ({ row }) => { + const quantity = row.getValue("quantity") as number | null; + return ( + <div className="text-center"> + {quantity !== null ? quantity.toLocaleString() : "-"} + </div> + ); + }, + }, + { + accessorKey: "uom", + header: ({ column }) => ( + <DataTableColumnHeader column={column} title="UoM" /> + ), + cell: ({ row }) => row.getValue("uom") || "-", + }, + ], + [] + ) + + const { table } = useDataTable({ + data: items, + columns, + pageCount: 1, + enablePinning: false, + enableAdvancedFilter: false, + }) + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-3xl"> + <DialogHeader> + <DialogTitle>RFQ Items</DialogTitle> + <DialogDescription> + Items included in this RFQ + </DialogDescription> + </DialogHeader> + <div className="mt-4"> + <DataTable table={table} /> + </div> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/vendors/service.ts b/lib/vendors/service.ts new file mode 100644 index 00000000..2da16888 --- /dev/null +++ b/lib/vendors/service.ts @@ -0,0 +1,1345 @@ +"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택) + +import { revalidateTag, unstable_noStore } from "next/cache"; +import db from "@/db/db"; +import { vendorAttachments, VendorContact, vendorContacts, vendorItemsView, vendorPossibleItems, vendors, type Vendor } from "@/db/schema/vendors"; +import logger from '@/lib/logger'; + +import { filterColumns } from "@/lib/filter-columns"; +import { unstable_cache } from "@/lib/unstable-cache"; +import { getErrorMessage } from "@/lib/handle-error"; + +import { + selectVendors, + countVendors, + insertVendor, + updateVendor, + updateVendors, groupByStatus, + getVendorById, + getVendorContactsById, + selectVendorContacts, + countVendorContacts, + insertVendorContact, + selectVendorItems, + countVendorItems, + insertVendorItem, + countRfqHistory, + selectRfqHistory +} from "./repository"; + +import type { + CreateVendorSchema, + UpdateVendorSchema, + GetVendorsSchema, + GetVendorContactsSchema, + CreateVendorContactSchema, + GetVendorItemsSchema, + CreateVendorItemSchema, + GetRfqHistorySchema, +} from "./validations"; + +import { asc, desc, ilike, inArray, and, or, gte, lte, eq, isNull } from "drizzle-orm"; +import { rfqItems, rfqs, vendorRfqView } from "@/db/schema/rfq"; +import path from "path"; +import fs from "fs/promises"; +import { randomUUID } from "crypto"; +import JSZip from 'jszip'; +import { promises as fsPromises } from 'fs'; +import { sendEmail } from "../mail/sendEmail"; +import { PgTransaction } from "drizzle-orm/pg-core"; +import { items } from "@/db/schema/items"; +import { id_ID } from "@faker-js/faker"; +import { users } from "@/db/schema/users"; + + +/* ----------------------------------------------------- + 1) 조회 관련 +----------------------------------------------------- */ + +/** + * 복잡한 조건으로 Vendor 목록을 조회 (+ pagination) 하고, + * 총 개수에 따라 pageCount를 계산해서 리턴. + * Next.js의 unstable_cache를 사용해 일정 시간 캐시. + */ +export async function getVendors(input: GetVendorsSchema) { + return unstable_cache( + async () => { + try { + const offset = (input.page - 1) * input.perPage; + + // 1) 고급 필터 + const advancedWhere = filterColumns({ + table: vendors, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + // 2) 글로벌 검색 + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + ilike(vendors.vendorName, s), + ilike(vendors.vendorCode, s), + ilike(vendors.email, s), + ilike(vendors.status, s) + ); + } + + // 최종 where 결합 + const finalWhere = and(advancedWhere, globalWhere); + + // 간단 검색 (advancedTable=false) 시 예시 + const simpleWhere = and( + input.vendorName + ? ilike(vendors.vendorName, `%${input.vendorName}%`) + : undefined, + input.status ? ilike(vendors.status, input.status) : undefined, + input.country + ? ilike(vendors.country, `%${input.country}%`) + : undefined + ); + + // 실제 사용될 where + const where = finalWhere; + + // 정렬 + const orderBy = + input.sort.length > 0 + ? input.sort.map((item) => + item.desc ? desc(vendors[item.id]) : asc(vendors[item.id]) + ) + : [asc(vendors.createdAt)]; + + // 트랜잭션 내에서 데이터 조회 + const { data, total } = await db.transaction(async (tx) => { + // 1) vendor 목록 조회 + const vendorsData = await selectVendors(tx, { + where, + orderBy, + offset, + limit: input.perPage, + }); + + // 2) 각 vendor의 attachments 조회 + const vendorsWithAttachments = await Promise.all( + vendorsData.map(async (vendor) => { + const attachments = await tx + .select({ + id: vendorAttachments.id, + fileName: vendorAttachments.fileName, + filePath: vendorAttachments.filePath, + }) + .from(vendorAttachments) + .where(eq(vendorAttachments.vendorId, vendor.id)); + + return { + ...vendor, + hasAttachments: attachments.length > 0, + attachmentsList: attachments, + }; + }) + ); + + // 3) 전체 개수 + const total = await countVendors(tx, where); + return { data: vendorsWithAttachments, total }; + }); + + // 페이지 수 + const pageCount = Math.ceil(total / input.perPage); + + return { data, pageCount }; + } catch (err) { + // 에러 발생 시 + return { data: [], pageCount: 0 }; + } + }, + [JSON.stringify(input)], // 캐싱 키 + { + revalidate: 3600, + tags: ["vendors"], // revalidateTag("vendors") 호출 시 무효화 + } + )(); +} + + +export async function getVendorStatusCounts() { + return unstable_cache( + async () => { + try { + + const initial: Record<Vendor["status"], number> = { + ACTIVE: 0, + INACTIVE: 0, + BLACKLISTED: 0, + "PENDING_REVIEW": 0, + "IN_REVIEW": 0, + "REJECTED": 0, + "IN_PQ": 0, + "PQ_FAILED": 0, + "APPROVED": 0, + "PQ_SUBMITTED": 0 + }; + + + const result = await db.transaction(async (tx) => { + const rows = await groupByStatus(tx); + return rows.reduce<Record<Vendor["status"], number>>((acc, { status, count }) => { + acc[status] = count; + return acc; + }, initial); + }); + + return result; + } catch (err) { + return {} as Record<Vendor["status"], number>; + } + }, + ["task-status-counts"], // 캐싱 키 + { + revalidate: 3600, + } + )(); +} + +/* ----------------------------------------------------- + 2) 생성(Create) +----------------------------------------------------- */ + +/** + * 신규 Vendor 생성 + */ + +async function storeVendorFiles( + tx: PgTransaction<any, any, any>, + vendorId: number, + files: File[], + attachmentType: string +) { + const vendorDir = path.join( + process.cwd(), + "public", + "vendors", + String(vendorId) + ) + await fs.mkdir(vendorDir, { recursive: true }) + + for (const file of files) { + // Convert file to buffer + const ab = await file.arrayBuffer() + const buffer = Buffer.from(ab) + + // Generate a unique filename + const uniqueName = `${randomUUID()}-${file.name}` + const relativePath = path.join("vendors", String(vendorId), uniqueName) + const absolutePath = path.join(process.cwd(), "public", relativePath) + + // Write to disk + await fs.writeFile(absolutePath, buffer) + + // Insert attachment record + await tx.insert(vendorAttachments).values({ + vendorId, + fileName: file.name, + filePath: "/" + relativePath.replace(/\\/g, "/"), + attachmentType, // "GENERAL", "CREDIT_RATING", "CASH_FLOW_RATING", ... + }) + } +} + +export type CreateVendorData = { + vendorName: string + vendorCode?: string + website?: string + taxId: string + address?: string + email: string + phone?: string + + representativeName?: string + representativeBirth?: string + representativeEmail?: string + representativePhone?: string + + creditAgency?: string + creditRating?: string + cashFlowRating?: string + corporateRegistrationNumber?: string + + country?: string + status?: "PENDING_REVIEW" | "IN_REVIEW" | "IN_PQ" | "PQ_FAILED" | "APPROVED" | "ACTIVE" | "INACTIVE" | "BLACKLISTED" | "PQ_SUBMITTED" +} + +export async function createVendor(params: { + vendorData: CreateVendorData + // 기존의 일반 첨부파일 + files?: File[] + + // 신용평가 / 현금흐름 등급 첨부 + creditRatingFiles?: File[] + cashFlowRatingFiles?: File[] + contacts: { + contactName: string + contactPosition?: string + contactEmail: string + contactPhone?: string + isPrimary?: boolean + }[] +}) { + unstable_noStore() // Next.js 서버 액션 캐싱 방지 + + try { + const { vendorData, files = [], creditRatingFiles = [], cashFlowRatingFiles = [], contacts } = params + + // 이메일 중복 검사 - 이미 users 테이블에 존재하는지 확인 + const existingUser = await db + .select({ id: users.id }) + .from(users) + .where(eq(users.email, vendorData.email)) + .limit(1); + + // 이미 사용자가 존재하면 에러 반환 + if (existingUser.length > 0) { + return { + data: null, + error: `이미 등록된 이메일입니다. 다른 이메일을 사용해주세요. (Email ${vendorData.email} already exists in the system)` + }; + } + + await db.transaction(async (tx) => { + // 1) Insert the vendor (확장 필드도 함께) + const [newVendor] = await insertVendor(tx, { + vendorName: vendorData.vendorName, + vendorCode: vendorData.vendorCode || null, + address: vendorData.address || null, + country: vendorData.country || null, + phone: vendorData.phone || null, + email: vendorData.email, + website: vendorData.website || null, + status: vendorData.status ?? "PENDING_REVIEW", + taxId: vendorData.taxId, + + // 대표자 정보 + representativeName: vendorData.representativeName || null, + representativeBirth: vendorData.representativeBirth || null, + representativeEmail: vendorData.representativeEmail || null, + representativePhone: vendorData.representativePhone || null, + corporateRegistrationNumber: vendorData.corporateRegistrationNumber || null, + + // 신용/현금흐름 + creditAgency: vendorData.creditAgency || null, + creditRating: vendorData.creditRating || null, + cashFlowRating: vendorData.cashFlowRating || null, + }) + + // 2) If there are attached files, store them + // (2-1) 일반 첨부 + if (files.length > 0) { + await storeVendorFiles(tx, newVendor.id, files, "GENERAL") + } + + // (2-2) 신용평가 파일 + if (creditRatingFiles.length > 0) { + await storeVendorFiles(tx, newVendor.id, creditRatingFiles, "CREDIT_RATING") + } + + // (2-3) 현금흐름 파일 + if (cashFlowRatingFiles.length > 0) { + await storeVendorFiles(tx, newVendor.id, cashFlowRatingFiles, "CASH_FLOW_RATING") + } + + for (const contact of contacts) { + await tx.insert(vendorContacts).values({ + vendorId: newVendor.id, + contactName: contact.contactName, + contactPosition: contact.contactPosition || null, + contactEmail: contact.contactEmail, + contactPhone: contact.contactPhone || null, + isPrimary: contact.isPrimary ?? false, + }) + } + }) + + revalidateTag("vendors") + return { data: null, error: null } + } catch (error) { + return { data: null, error: getErrorMessage(error) } + } +} +/* ----------------------------------------------------- + 3) 업데이트 (단건/복수) +----------------------------------------------------- */ + +/** 단건 업데이트 */ +export async function modifyVendor( + input: UpdateVendorSchema & { id: string } +) { + unstable_noStore(); + try { + const updated = await db.transaction(async (tx) => { + // 특정 ID 벤더를 업데이트 + const [res] = await updateVendor(tx, input.id, { + vendorName: input.vendorName, + vendorCode: input.vendorCode, + address: input.address, + country: input.country, + phone: input.phone, + email: input.email, + website: input.website, + status: input.status, + }); + return res; + }); + + // 필요 시, status 변경 등에 따른 다른 캐시도 무효화 + revalidateTag("vendors"); + revalidateTag("rfq-vendors"); + + return { data: updated, error: null }; + } catch (err) { + return { data: null, error: getErrorMessage(err) }; + } +} + +/** 복수 업데이트 */ +export async function modifyVendors(input: { + ids: string[]; + status?: Vendor["status"]; +}) { + unstable_noStore(); + try { + const data = await db.transaction(async (tx) => { + // 여러 벤더 일괄 업데이트 + const [updated] = await updateVendors(tx, input.ids, { + // 예: 상태만 일괄 변경 + status: input.status, + }); + return updated; + }); + + revalidateTag("vendors"); + if (data.status === input.status) { + revalidateTag("vendor-status-counts"); + } + return { data: null, error: null }; + } catch (err) { + return { data: null, error: getErrorMessage(err) }; + } +} + +export const findVendorById = async (id: number): Promise<Vendor | null> => { + try { + logger.info({ id }, 'Fetching user by ID'); + const vendor = await getVendorById(id); + if (!vendor) { + logger.warn({ id }, 'User not found'); + } else { + logger.debug({ vendor }, 'User fetched successfully'); + } + return vendor; + } catch (error) { + logger.error({ error }, 'Error fetching user by ID'); + throw new Error('Failed to fetch user'); + } +}; + + +export const findVendorContactsById = async (id: number): Promise<VendorContact | null> => { + try { + logger.info({ id }, 'Fetching user by ID'); + const vendor = await getVendorContactsById(id); + if (!vendor) { + logger.warn({ id }, 'User not found'); + } else { + logger.debug({ vendor }, 'User fetched successfully'); + } + return vendor; + } catch (error) { + logger.error({ error }, 'Error fetching user by ID'); + throw new Error('Failed to fetch user'); + } +}; + + +export async function getVendorContacts(input: GetVendorContactsSchema, id: number) { + return unstable_cache( + async () => { + try { + const offset = (input.page - 1) * input.perPage; + + // const advancedTable = input.flags.includes("advancedTable"); + const advancedTable = true; + + + // advancedTable 모드면 filterColumns()로 where 절 구성 + const advancedWhere = filterColumns({ + table: vendorContacts, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + + let globalWhere + if (input.search) { + const s = `%${input.search}%` + globalWhere = or(ilike(vendorContacts.contactName, s), ilike(vendorContacts.contactPosition, s) + , ilike(vendorContacts.contactEmail, s), ilike(vendorContacts.contactPhone, s) + ) + // 필요시 여러 칼럼 OR조건 (status, priority, etc) + } + + const vendorWhere = eq(vendorContacts.vendorId, id) + + const finalWhere = and( + // advancedWhere or your existing conditions + advancedWhere, + globalWhere, + vendorWhere + ) + + + // 아니면 ilike, inArray, gte 등으로 where 절 구성 + const where = finalWhere + + const orderBy = + input.sort.length > 0 + ? input.sort.map((item) => + item.desc ? desc(vendorContacts[item.id]) : asc(vendorContacts[item.id]) + ) + : [asc(vendorContacts.createdAt)]; + + // 트랜잭션 내부에서 Repository 호출 + const { data, total } = await db.transaction(async (tx) => { + const data = await selectVendorContacts(tx, { + where, + orderBy, + offset, + limit: input.perPage, + }); + const total = await countVendorContacts(tx, where); + return { data, total }; + }); + + + + const pageCount = Math.ceil(total / input.perPage); + + + return { data, pageCount }; + } catch (err) { + // 에러 발생 시 디폴트 + return { data: [], pageCount: 0 }; + } + }, + [JSON.stringify(input), String(id)], // 캐싱 키 + { + revalidate: 3600, + tags: [`vendor-contacts-${id}`], // revalidateTag("tasks") 호출 시 무효화 + } + )(); +} + +export async function createVendorContact(input: CreateVendorContactSchema) { + unstable_noStore(); // Next.js 서버 액션 캐싱 방지 + try { + await db.transaction(async (tx) => { + // DB Insert + const [newContact] = await insertVendorContact(tx, { + vendorId: input.vendorId, + contactName: input.contactName, + contactPosition: input.contactPosition || "", + contactEmail: input.contactEmail, + contactPhone: input.contactPhone || "", + isPrimary: input.isPrimary || false, + }); + return newContact; + }); + + // 캐시 무효화 (벤더 연락처 목록 등) + revalidateTag(`vendor-contacts-${input.vendorId}`); + + return { data: null, error: null }; + } catch (err) { + return { data: null, error: getErrorMessage(err) }; + } +} + + +///item + +export async function getVendorItems(input: GetVendorItemsSchema, id: number) { + const cachedFunction = unstable_cache( + + async () => { + try { + const offset = (input.page - 1) * input.perPage; + + // const advancedTable = input.flags.includes("advancedTable"); + const advancedTable = true; + + // advancedTable 모드면 filterColumns()로 where 절 구성 + const advancedWhere = filterColumns({ + table: vendorItemsView, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + + let globalWhere + if (input.search) { + const s = `%${input.search}%` + globalWhere = or(ilike(vendorItemsView.itemCode, s) + , ilike(vendorItemsView.description, s) + ) + // 필요시 여러 칼럼 OR조건 (status, priority, etc) + } + + const vendorWhere = eq(vendorItemsView.vendorId, id) + + const finalWhere = and( + // advancedWhere or your existing conditions + advancedWhere, + globalWhere, + vendorWhere + ) + + + // 아니면 ilike, inArray, gte 등으로 where 절 구성 + const where = finalWhere + + const orderBy = + input.sort.length > 0 + ? input.sort.map((item) => + item.desc ? desc(vendorItemsView[item.id]) : asc(vendorItemsView[item.id]) + ) + : [asc(vendorItemsView.createdAt)]; + + // 트랜잭션 내부에서 Repository 호출 + const { data, total } = await db.transaction(async (tx) => { + const data = await selectVendorItems(tx, { + where, + orderBy, + offset, + limit: input.perPage, + }); + const total = await countVendorItems(tx, where); + return { data, total }; + }); + + + const pageCount = Math.ceil(total / input.perPage); + + + console.log(data) + + return { data, pageCount }; + } catch (err) { + // 에러 발생 시 디폴트 + return { data: [], pageCount: 0 }; + } + }, + [JSON.stringify(input), String(id)], // 캐싱 키 + { + revalidate: 3600, + tags: [`vendor-items-${id}`], // revalidateTag("tasks") 호출 시 무효화 + } + ); + return cachedFunction(); +} + +export interface ItemDropdownOption { + itemCode: string; + itemName: string; + description: string | null; +} + +/** + * Vendor Item 추가 시 사용할 아이템 목록 조회 (전체 목록 반환) + * 아이템 코드, 이름, 설명만 간소화해서 반환 + */ +export async function getItemsForVendor(vendorId: number) { + return unstable_cache( + async () => { + try { + // 해당 vendorId가 이미 가지고 있는 itemCode 목록을 서브쿼리로 구함 + // 그 아이템코드를 제외(notIn)하여 모든 items 테이블에서 조회 + const itemsData = await db + .select({ + itemCode: items.itemCode, + itemName: items.itemName, + description: items.description, + }) + .from(items) + .leftJoin( + vendorPossibleItems, + eq(items.itemCode, vendorPossibleItems.itemCode) + ) + // vendorPossibleItems.vendorId가 이 vendorId인 행이 없는(즉 아직 등록되지 않은) 아이템만 + .where( + isNull(vendorPossibleItems.id) // 또는 isNull(vendorPossibleItems.itemCode) + ) + .orderBy(asc(items.itemName)) + + return { + data: itemsData.map((item) => ({ + itemCode: item.itemCode ?? "", // null이라면 ""로 치환 + itemName: item.itemName, + description: item.description ?? "" // null이라면 ""로 치환 + })), + error: null + } + } catch (err) { + console.error("Failed to fetch items for vendor dropdown:", err) + return { + data: [], + error: "아이템 목록을 불러오는데 실패했습니다.", + } + } + }, + // 캐시 키를 vendorId 별로 달리 해야 한다. + ["items-for-vendor", String(vendorId)], + { + revalidate: 3600, // 1시간 캐싱 + tags: ["items"], // revalidateTag("items") 호출 시 무효화 + } + )() +} + +export async function createVendorItem(input: CreateVendorItemSchema) { + unstable_noStore(); // Next.js 서버 액션 캐싱 방지 + try { + await db.transaction(async (tx) => { + // DB Insert + const [newContact] = await insertVendorItem(tx, { + vendorId: input.vendorId, + itemCode: input.itemCode, + + }); + return newContact; + }); + + // 캐시 무효화 (벤더 연락처 목록 등) + revalidateTag(`vendor-items-${input.vendorId}`); + + return { data: null, error: null }; + } catch (err) { + return { data: null, error: getErrorMessage(err) }; + } +} + +export async function getRfqHistory(input: GetRfqHistorySchema, vendorId: number) { + return unstable_cache( + async () => { + try { + logger.info({ vendorId, input }, "Starting getRfqHistory"); + + const offset = (input.page - 1) * input.perPage; + + // 기본 where 조건 (vendorId) + const vendorWhere = eq(vendorRfqView.vendorId, vendorId); + logger.debug({ vendorWhere }, "Vendor where condition"); + + // 고급 필터링 + const advancedWhere = filterColumns({ + table: vendorRfqView, + filters: input.filters, + joinOperator: input.joinOperator, + }); + logger.debug({ advancedWhere }, "Advanced where condition"); + + // 글로벌 검색 + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + ilike(vendorRfqView.rfqCode, s), + ilike(vendorRfqView.projectCode, s), + ilike(vendorRfqView.projectName, s) + ); + logger.debug({ globalWhere, search: input.search }, "Global search condition"); + } + + const finalWhere = and( + advancedWhere, + globalWhere, + vendorWhere + ); + logger.debug({ finalWhere }, "Final where condition"); + + // 정렬 조건 + const orderBy = + input.sort.length > 0 + ? input.sort.map((item) => + item.desc ? desc(rfqs[item.id]) : asc(rfqs[item.id]) + ) + : [desc(rfqs.createdAt)]; + logger.debug({ orderBy }, "Order by condition"); + + // 트랜잭션으로 데이터 조회 + const { data, total } = await db.transaction(async (tx) => { + logger.debug("Starting transaction for RFQ history query"); + + const data = await selectRfqHistory(tx, { + where: finalWhere, + orderBy, + offset, + limit: input.perPage, + }); + logger.debug({ dataLength: data.length }, "RFQ history data fetched"); + + // RFQ 아이템 정보 조회 + const rfqIds = data.map(rfq => rfq.id); + const items = await tx + .select({ + rfqId: rfqItems.rfqId, + id: rfqItems.id, + itemCode: rfqItems.itemCode, + description: rfqItems.description, + quantity: rfqItems.quantity, + uom: rfqItems.uom, + }) + .from(rfqItems) + .where(inArray(rfqItems.rfqId, rfqIds)); + + // RFQ 데이터에 아이템 정보 추가 + const dataWithItems = data.map(rfq => ({ + ...rfq, + items: items.filter(item => item.rfqId === rfq.id), + })); + + const total = await countRfqHistory(tx, finalWhere); + logger.debug({ total }, "RFQ history total count"); + + return { data: dataWithItems, total }; + }); + + const pageCount = Math.ceil(total / input.perPage); + logger.info({ + vendorId, + dataLength: data.length, + total, + pageCount + }, "RFQ history query completed"); + + return { data, pageCount }; + } catch (err) { + logger.error({ + err, + vendorId, + stack: err instanceof Error ? err.stack : undefined + }, 'Error fetching RFQ history'); + return { data: [], pageCount: 0 }; + } + }, + [JSON.stringify({ input, vendorId })], + { + revalidate: 3600, + tags: ["rfq-history"], + } + )(); +} + +export async function checkJoinPortal(taxID: string) { + try { + // 이미 등록된 회사가 있는지 검색 + const result = await db.select().from(vendors).where(eq(vendors.taxId, taxID)).limit(1) + + if (result.length > 0) { + // 이미 가입되어 있음 + // data에 예시로 vendorName이나 다른 정보를 담아 반환 + return { + success: false, + data: result[0].vendorName ?? "Already joined", + } + } + + // 미가입 → 가입 가능 + return { + success: true, + } + } catch (err) { + console.error("checkJoinPortal error:", err) + // 서버 에러 시 + return { + success: false, + data: "서버 에러가 발생했습니다.", + } + } +} + +interface CreateCompanyInput { + vendorName: string + taxId: string + email: string + address: string + phone?: string + country?: string + // 필요한 필드 추가 가능 (vendorCode, website 등) +} + + +/** + * 벤더 첨부파일 다운로드를 위한 서버 액션 + * @param vendorId 벤더 ID + * @param fileId 특정 파일 ID (단일 파일 다운로드시) + * @returns 다운로드할 수 있는 임시 URL + */ +export async function downloadVendorAttachments(vendorId: number, fileId?: number) { + try { + // 벤더 정보 조회 + const vendor = await db.select() + .from(vendors) + .where(eq(vendors.id, vendorId)) + .limit(1) + .then(rows => rows[0]); + + if (!vendor) { + throw new Error(`벤더 정보를 찾을 수 없습니다. (ID: ${vendorId})`); + } + + // 첨부파일 조회 (특정 파일 또는 모든 파일) + const attachments = fileId + ? await db.select() + .from(vendorAttachments) + .where(eq(vendorAttachments.id, fileId)) + : await db.select() + .from(vendorAttachments) + .where(eq(vendorAttachments.vendorId, vendorId)); + + if (!attachments.length) { + throw new Error('다운로드할 첨부파일이 없습니다.'); + } + + // 업로드 기본 경로 + const basePath = process.env.UPLOAD_DIR || path.join(process.cwd(), 'uploads'); + + // 단일 파일인 경우 직접 URL 반환 + if (attachments.length === 1) { + const attachment = attachments[0]; + const filePath = `/api/vendors/attachments/download?id=${attachment.id}`; + return { url: filePath, fileName: attachment.fileName }; + } + + // 다중 파일: 임시 ZIP 생성 후 URL 반환 + // 임시 디렉토리 생성 + const tempDir = path.join(process.cwd(), 'tmp'); + await fsPromises.mkdir(tempDir, { recursive: true }); + + // 고유 ID로 임시 ZIP 파일명 생성 + const tempId = randomUUID(); + const zipFileName = `${vendor.vendorName || `vendor-${vendorId}`}-attachments-${tempId}.zip`; + const zipFilePath = path.join(tempDir, zipFileName); + + // JSZip을 사용하여 ZIP 파일 생성 + const zip = new JSZip(); + + // 파일 읽기 및 추가 작업을 병렬로 처리 + await Promise.all( + attachments.map(async (attachment) => { + const filePath = path.join(basePath, attachment.filePath); + + try { + // 파일 존재 확인 (fsPromises.access 사용) + try { + await fsPromises.access(filePath, fs.constants.F_OK); + } catch (e) { + console.warn(`파일이 존재하지 않습니다: ${filePath}`); + return; // 파일이 없으면 건너뜀 + } + + // 파일 읽기 (fsPromises.readFile 사용) + const fileData = await fsPromises.readFile(filePath); + + // ZIP에 파일 추가 + zip.file(attachment.fileName, fileData); + } catch (error) { + console.warn(`파일을 처리할 수 없습니다: ${filePath}`, error); + // 오류가 있더라도 계속 진행 + } + }) + ); + + // ZIP 생성 및 저장 + const zipContent = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE', compressionOptions: { level: 9 } }); + await fsPromises.writeFile(zipFilePath, zipContent); + + // 임시 ZIP 파일에 접근할 수 있는 URL 생성 + const downloadUrl = `/api/vendors/attachments/download-temp?file=${encodeURIComponent(zipFileName)}`; + + return { + url: downloadUrl, + fileName: `${vendor.vendorName || `vendor-${vendorId}`}-attachments.zip` + }; + } catch (error) { + console.error('첨부파일 다운로드 서버 액션 오류:', error); + throw new Error('첨부파일 다운로드 준비 중 오류가 발생했습니다.'); + } +} + +/** + * 임시 ZIP 파일 정리를 위한 서버 액션 + * @param fileName 정리할 파일명 + */ +export async function cleanupTempFiles(fileName: string) { + 'use server'; + + try { + const tempDir = path.join(process.cwd(), 'tmp'); + const filePath = path.join(tempDir, fileName); + + try { + // 파일 존재 확인 + await fsPromises.access(filePath, fs.constants.F_OK); + // 파일 삭제 + await fsPromises.unlink(filePath); + } catch { + // 파일이 없으면 무시 + } + + return { success: true }; + } catch (error) { + console.error('임시 파일 정리 오류:', error); + return { success: false, error: '임시 파일 정리 중 오류가 발생했습니다.' }; + } +} + + +interface ApproveVendorsInput { + ids: number[]; +} + +/** + * 선택된 벤더의 상태를 IN_REVIEW로 변경하고 이메일 알림을 발송하는 서버 액션 + */ +export async function approveVendors(input: ApproveVendorsInput) { + unstable_noStore(); + + try { + // 트랜잭션 내에서 벤더 상태 업데이트, 유저 생성 및 이메일 발송 + const result = await db.transaction(async (tx) => { + // 1. 벤더 상태 업데이트 + const [updated] = await tx + .update(vendors) + .set({ + status: "IN_REVIEW", + updatedAt: new Date() + }) + .where(inArray(vendors.id, input.ids)) + .returning(); + + // 2. 업데이트된 벤더 정보 조회 + const updatedVendors = await tx + .select({ + id: vendors.id, + vendorName: vendors.vendorName, + email: vendors.email, + }) + .from(vendors) + .where(inArray(vendors.id, input.ids)); + + // 3. 각 벤더에 대한 유저 계정 생성 + await Promise.all( + updatedVendors.map(async (vendor) => { + if (!vendor.email) return; // 이메일이 없으면 스킵 + + // 이미 존재하는 유저인지 확인 + const existingUser = await tx + .select({ id: users.id }) + .from(users) + .where(eq(users.email, vendor.email)) + .limit(1); + + // 유저가 존재하지 않는 경우에만 생성 + if (existingUser.length === 0) { + await tx.insert(users).values({ + name: vendor.vendorName, + email: vendor.email, + companyId: vendor.id, + domain: "partners", // 기본값으로 이미 설정되어 있지만 명시적으로 지정 + }); + } + }) + ); + + // 4. 각 벤더에게 이메일 발송 + await Promise.all( + updatedVendors.map(async (vendor) => { + if (!vendor.email) return; // 이메일이 없으면 스킵 + + try { + const userLang = "en"; // 기본값, 필요시 벤더 언어 설정에서 가져오기 + + const subject = + "[eVCP] Admin Account Created"; + + const loginUrl = "http://3.36.56.124:3000/en/login"; + + await sendEmail({ + to: vendor.email, + subject, + template: "admin-created", // 이메일 템플릿 이름 + context: { + vendorName: vendor.vendorName, + loginUrl, + language: userLang, + }, + }); + } catch (emailError) { + console.error(`Failed to send email to vendor ${vendor.id}:`, emailError); + // 이메일 전송 실패는 전체 트랜잭션을 실패시키지 않음 + } + }) + ); + + return updated; + }); + + // 캐시 무효화 + revalidateTag("vendors"); + revalidateTag("vendor-status-counts"); + revalidateTag("users"); // 유저 캐시도 무효화 + + return { data: result, error: null }; + } catch (err) { + console.error("Error approving vendors:", err); + return { data: null, error: getErrorMessage(err) }; + } +} +export async function requestPQVendors(input: ApproveVendorsInput) { + unstable_noStore(); + + try { + // 트랜잭션 내에서 벤더 상태 업데이트 및 이메일 발송 + const result = await db.transaction(async (tx) => { + // 1. 벤더 상태 업데이트 + const [updated] = await tx + .update(vendors) + .set({ + status: "IN_PQ", + updatedAt: new Date() + }) + .where(inArray(vendors.id, input.ids)) + .returning(); + + // 2. 업데이트된 벤더 정보 조회 + const updatedVendors = await tx + .select({ + id: vendors.id, + vendorName: vendors.vendorName, + email: vendors.email, + }) + .from(vendors) + .where(inArray(vendors.id, input.ids)); + + // 3. 각 벤더에게 이메일 발송 + await Promise.all( + updatedVendors.map(async (vendor) => { + if (!vendor.email) return; // 이메일이 없으면 스킵 + + try { + const userLang = "en"; // 기본값, 필요시 벤더 언어 설정에서 가져오기 + + const subject = + "[eVCP] You are invited to submit PQ"; + + const loginUrl = "http://3.36.56.124:3000/en/login"; + + await sendEmail({ + to: vendor.email, + subject, + template: "pq", // 이메일 템플릿 이름 + context: { + vendorName: vendor.vendorName, + loginUrl, + language: userLang, + }, + }); + } catch (emailError) { + console.error(`Failed to send email to vendor ${vendor.id}:`, emailError); + // 이메일 전송 실패는 전체 트랜잭션을 실패시키지 않음 + } + }) + ); + + return updated; + }); + + // 캐시 무효화 + revalidateTag("vendors"); + revalidateTag("vendor-status-counts"); + + return { data: result, error: null }; + } catch (err) { + console.error("Error approving vendors:", err); + return { data: null, error: getErrorMessage(err) }; + } +} + +interface SendVendorsInput { + ids: number[]; +} + +/** + * APPROVED 상태인 벤더 정보를 기간계 시스템에 전송하고 벤더 코드를 업데이트하는 서버 액션 + */ +export async function sendVendors(input: SendVendorsInput) { + unstable_noStore(); + + try { + // 트랜잭션 내에서 진행 + const result = await db.transaction(async (tx) => { + // 1. 선택된 벤더 중 APPROVED 상태인 벤더만 필터링 + const approvedVendors = await tx + .select() + .from(vendors) + .where( + and( + inArray(vendors.id, input.ids), + eq(vendors.status, "APPROVED") + ) + ); + + if (!approvedVendors.length) { + throw new Error("No approved vendors found in the selection"); + } + + // 벤더별 처리 결과를 저장할 배열 + const results = []; + + // 2. 각 벤더에 대해 처리 + for (const vendor of approvedVendors) { + // 2-1. 벤더 연락처 정보 조회 + const contacts = await tx + .select() + .from(vendorContacts) + .where(eq(vendorContacts.vendorId, vendor.id)); + + // 2-2. 벤더 가능 아이템 조회 + const possibleItems = await tx + .select() + .from(vendorPossibleItems) + .where(eq(vendorPossibleItems.vendorId, vendor.id)); + + // 2-3. 벤더 첨부파일 조회 + const attachments = await tx + .select({ + id: vendorAttachments.id, + fileName: vendorAttachments.fileName, + filePath: vendorAttachments.filePath, + }) + .from(vendorAttachments) + .where(eq(vendorAttachments.vendorId, vendor.id)); + + // 2-4. 벤더 정보를 기간계 시스템에 전송 (NextJS API 라우트 사용) + const vendorData = { + id: vendor.id, + vendorName: vendor.vendorName, + taxId: vendor.taxId, + address: vendor.address || "", + country: vendor.country || "", + phone: vendor.phone || "", + email: vendor.email || "", + website: vendor.website || "", + contacts, + possibleItems, + attachments, + }; + + try { + // 내부 API 호출 (기간계 시스템 연동 API) + const erpResponse = await fetch(`/api/erp/vendors`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(vendorData), + }); + + if (!erpResponse.ok) { + const errorData = await erpResponse.json(); + throw new Error(`ERP system error for vendor ${vendor.id}: ${errorData.message || erpResponse.statusText}`); + } + + const responseData = await erpResponse.json(); + + if (!responseData.success || !responseData.vendorCode) { + throw new Error(`Invalid response from ERP system for vendor ${vendor.id}`); + } + + // 2-5. 벤더 코드 및 상태 업데이트 + const vendorCode = responseData.vendorCode; + + const [updated] = await tx + .update(vendors) + .set({ + vendorCode, + status: "ACTIVE", // 상태를 ACTIVE로 변경 + updatedAt: new Date(), + }) + .where(eq(vendors.id, vendor.id)) + .returning(); + + // 2-6. 벤더에게 알림 이메일 발송 + if (vendor.email) { + const userLang = "en"; // 기본값, 필요시 벤더 언어 설정에서 가져오기 + + const subject = + "[eVCP] Vendor Registration Completed"; + + const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://3.36.56.124:3000' + + const portalUrl = `${baseUrl}/en/partners`; + + await sendEmail({ + to: vendor.email, + subject, + template: "vendor-active", + context: { + vendorName: vendor.vendorName, + vendorCode, + portalUrl, + language: userLang, + }, + }); + } + + results.push({ + id: vendor.id, + success: true, + vendorCode, + message: "Successfully sent to ERP system", + }); + } catch (vendorError) { + // 개별 벤더 처리 오류 기록 + results.push({ + id: vendor.id, + success: false, + error: getErrorMessage(vendorError), + }); + } + } + + // 3. 처리 결과 반환 + const successCount = results.filter(r => r.success).length; + const failCount = results.filter(r => !r.success).length; + + return { + totalProcessed: results.length, + successCount, + failCount, + results, + }; + }); + + // 캐시 무효화 + revalidateTag("vendors"); + revalidateTag("vendor-status-counts"); + + return { data: result, error: null }; + } catch (err) { + console.error("Error sending vendors to ERP:", err); + return { data: null, error: getErrorMessage(err) }; + } +} + diff --git a/lib/vendors/table/approve-vendor-dialog.tsx b/lib/vendors/table/approve-vendor-dialog.tsx new file mode 100644 index 00000000..253c2830 --- /dev/null +++ b/lib/vendors/table/approve-vendor-dialog.tsx @@ -0,0 +1,150 @@ +"use client" + +import * as React from "react" +import { type Row } from "@tanstack/react-table" +import { Loader, Check } from "lucide-react" +import { toast } from "sonner" + +import { useMediaQuery } from "@/hooks/use-media-query" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer" +import { Vendor } from "@/db/schema/vendors" +import { approveVendors } from "../service" + +interface ApprovalVendorDialogProps + extends React.ComponentPropsWithoutRef<typeof Dialog> { + vendors: Row<Vendor>["original"][] + showTrigger?: boolean + onSuccess?: () => void +} + +export function ApproveVendorsDialog({ + vendors, + showTrigger = true, + onSuccess, + ...props +}: ApprovalVendorDialogProps) { + const [isApprovePending, startApproveTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + + function onApprove() { + startApproveTransition(async () => { + const { error } = await approveVendors({ + ids: vendors.map((vendor) => vendor.id), + }) + + if (error) { + toast.error(error) + return + } + + props.onOpenChange?.(false) + toast.success("Vendors successfully approved for review") + onSuccess?.() + }) + } + + if (isDesktop) { + return ( + <Dialog {...props}> + {showTrigger ? ( + <DialogTrigger asChild> + <Button variant="outline" size="sm" className="gap-2"> + <Check className="size-4" aria-hidden="true" /> + Approve ({vendors.length}) + </Button> + </DialogTrigger> + ) : null} + <DialogContent> + <DialogHeader> + <DialogTitle>Confirm Vendor Approval</DialogTitle> + <DialogDescription> + Are you sure you want to approve{" "} + <span className="font-medium">{vendors.length}</span> + {vendors.length === 1 ? " vendor" : " vendors"}? + After approval, vendors will be notified and can login to submit PQ information. + </DialogDescription> + </DialogHeader> + <DialogFooter className="gap-2 sm:space-x-0"> + <DialogClose asChild> + <Button variant="outline">Cancel</Button> + </DialogClose> + <Button + aria-label="Approve selected vendors" + variant="default" + onClick={onApprove} + disabled={isApprovePending} + > + {isApprovePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + Approve + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) + } + + return ( + <Drawer {...props}> + {showTrigger ? ( + <DrawerTrigger asChild> + <Button variant="outline" size="sm" className="gap-2"> + <Check className="size-4" aria-hidden="true" /> + Approve ({vendors.length}) + </Button> + </DrawerTrigger> + ) : null} + <DrawerContent> + <DrawerHeader> + <DrawerTitle>Confirm Vendor Approval</DrawerTitle> + <DrawerDescription> + Are you sure you want to approve{" "} + <span className="font-medium">{vendors.length}</span> + {vendors.length === 1 ? " vendor" : " vendors"}? + After approval, vendors will be notified and can login to submit PQ information. + </DrawerDescription> + </DrawerHeader> + <DrawerFooter className="gap-2 sm:space-x-0"> + <DrawerClose asChild> + <Button variant="outline">Cancel</Button> + </DrawerClose> + <Button + aria-label="Approve selected vendors" + variant="default" + onClick={onApprove} + disabled={isApprovePending} + > + {isApprovePending && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + Approve + </Button> + </DrawerFooter> + </DrawerContent> + </Drawer> + ) +}
\ No newline at end of file diff --git a/lib/vendors/table/attachmentButton.tsx b/lib/vendors/table/attachmentButton.tsx new file mode 100644 index 00000000..a82f59e1 --- /dev/null +++ b/lib/vendors/table/attachmentButton.tsx @@ -0,0 +1,69 @@ +'use client'; + +import React from 'react'; +import { Button } from '@/components/ui/button'; +import { PaperclipIcon } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { toast } from 'sonner'; +import { type VendorAttach } from '@/db/schema/vendors'; +import { downloadVendorAttachments } from '../service'; + +interface AttachmentsButtonProps { + vendorId: number; + hasAttachments: boolean; + attachmentsList?: VendorAttach[]; +} + +export function AttachmentsButton({ vendorId, hasAttachments, attachmentsList = [] }: AttachmentsButtonProps) { + if (!hasAttachments) return null; + + const handleDownload = async () => { + try { + toast.loading('첨부파일을 준비하는 중...'); + + // 서버 액션 호출 + const result = await downloadVendorAttachments(vendorId); + + // 로딩 토스트 닫기 + toast.dismiss(); + + if (!result || !result.url) { + toast.error('다운로드 준비 중 오류가 발생했습니다.'); + return; + } + + // 파일 다운로드 트리거 + toast.success('첨부파일 다운로드가 시작되었습니다.'); + + // 다운로드 링크 열기 + const a = document.createElement('a'); + a.href = result.url; + a.download = result.fileName || '첨부파일.zip'; + a.style.display = 'none'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + + } catch (error) { + toast.dismiss(); + toast.error('첨부파일 다운로드에 실패했습니다.'); + console.error('첨부파일 다운로드 오류:', error); + } + }; + + return ( + <Button + variant="ghost" + size="icon" + onClick={handleDownload} + title={`${attachmentsList.length}개 파일 다운로드`} + > + <PaperclipIcon className="h-4 w-4" /> + {attachmentsList.length > 1 && ( + <Badge variant="outline" className="ml-1 h-5 min-w-5 px-1"> + {attachmentsList.length} + </Badge> + )} + </Button> + ); +} diff --git a/lib/vendors/table/feature-flags-provider.tsx b/lib/vendors/table/feature-flags-provider.tsx new file mode 100644 index 00000000..81131894 --- /dev/null +++ b/lib/vendors/table/feature-flags-provider.tsx @@ -0,0 +1,108 @@ +"use client" + +import * as React from "react" +import { useQueryState } from "nuqs" + +import { dataTableConfig, type DataTableConfig } from "@/config/data-table" +import { cn } from "@/lib/utils" +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" + +type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"] + +interface FeatureFlagsContextProps { + featureFlags: FeatureFlagValue[] + setFeatureFlags: (value: FeatureFlagValue[]) => void +} + +const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({ + featureFlags: [], + setFeatureFlags: () => {}, +}) + +export function useFeatureFlags() { + const context = React.useContext(FeatureFlagsContext) + if (!context) { + throw new Error( + "useFeatureFlags must be used within a FeatureFlagsProvider" + ) + } + return context +} + +interface FeatureFlagsProviderProps { + children: React.ReactNode +} + +export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) { + const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>( + "flags", + { + defaultValue: [], + parse: (value) => value.split(",") as FeatureFlagValue[], + serialize: (value) => value.join(","), + eq: (a, b) => + a.length === b.length && a.every((value, index) => value === b[index]), + clearOnDefault: true, + shallow: false, + } + ) + + return ( + <FeatureFlagsContext.Provider + value={{ + featureFlags, + setFeatureFlags: (value) => void setFeatureFlags(value), + }} + > + <div className="w-full overflow-x-auto"> + <ToggleGroup + type="multiple" + variant="outline" + size="sm" + value={featureFlags} + onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)} + className="w-fit gap-0" + > + {dataTableConfig.featureFlags.map((flag, index) => ( + <Tooltip key={flag.value}> + <ToggleGroupItem + value={flag.value} + className={cn( + "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90", + { + "rounded-l-sm border-r-0": index === 0, + "rounded-r-sm": + index === dataTableConfig.featureFlags.length - 1, + } + )} + asChild + > + <TooltipTrigger> + <flag.icon className="size-3.5 shrink-0" aria-hidden="true" /> + {flag.label} + </TooltipTrigger> + </ToggleGroupItem> + <TooltipContent + align="start" + side="bottom" + sideOffset={6} + className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground" + > + <div>{flag.tooltipTitle}</div> + <div className="text-xs text-muted-foreground"> + {flag.tooltipDescription} + </div> + </TooltipContent> + </Tooltip> + ))} + </ToggleGroup> + </div> + {children} + </FeatureFlagsContext.Provider> + ) +} diff --git a/lib/vendors/table/request-vendor-pg-dialog.tsx b/lib/vendors/table/request-vendor-pg-dialog.tsx new file mode 100644 index 00000000..b417f846 --- /dev/null +++ b/lib/vendors/table/request-vendor-pg-dialog.tsx @@ -0,0 +1,150 @@ +"use client" + +import * as React from "react" +import { type Row } from "@tanstack/react-table" +import { Loader, Check, SendHorizonal } from "lucide-react" +import { toast } from "sonner" + +import { useMediaQuery } from "@/hooks/use-media-query" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer" +import { Vendor } from "@/db/schema/vendors" +import { requestPQVendors } from "../service" + +interface ApprovalVendorDialogProps + extends React.ComponentPropsWithoutRef<typeof Dialog> { + vendors: Row<Vendor>["original"][] + showTrigger?: boolean + onSuccess?: () => void +} + +export function RequestPQVendorsDialog({ + vendors, + showTrigger = true, + onSuccess, + ...props +}: ApprovalVendorDialogProps) { + const [isApprovePending, startApproveTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + + function onApprove() { + startApproveTransition(async () => { + const { error } = await requestPQVendors({ + ids: vendors.map((vendor) => vendor.id), + }) + + if (error) { + toast.error(error) + return + } + + props.onOpenChange?.(false) + toast.success("PQ successfully sent to vendors") + onSuccess?.() + }) + } + + if (isDesktop) { + return ( + <Dialog {...props}> + {showTrigger ? ( + <DialogTrigger asChild> + <Button variant="outline" size="sm" className="gap-2"> + <SendHorizonal className="size-4" aria-hidden="true" /> + Request ({vendors.length}) + </Button> + </DialogTrigger> + ) : null} + <DialogContent> + <DialogHeader> + <DialogTitle>Confirm Vendor PQ requst</DialogTitle> + <DialogDescription> + Are you sure you want to request{" "} + <span className="font-medium">{vendors.length}</span> + {vendors.length === 1 ? " vendor" : " vendors"}? + After sent, vendors will be notified and can submit PQ information. + </DialogDescription> + </DialogHeader> + <DialogFooter className="gap-2 sm:space-x-0"> + <DialogClose asChild> + <Button variant="outline">Cancel</Button> + </DialogClose> + <Button + aria-label="Request selected vendors" + variant="default" + onClick={onApprove} + disabled={isApprovePending} + > + {isApprovePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + Request + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) + } + + return ( + <Drawer {...props}> + {showTrigger ? ( + <DrawerTrigger asChild> + <Button variant="outline" size="sm" className="gap-2"> + <Check className="size-4" aria-hidden="true" /> + Request ({vendors.length}) + </Button> + </DrawerTrigger> + ) : null} + <DrawerContent> + <DrawerHeader> + <DrawerTitle>Confirm Vendor Approval</DrawerTitle> + <DrawerDescription> + Are you sure you want to request{" "} + <span className="font-medium">{vendors.length}</span> + {vendors.length === 1 ? " vendor" : " vendors"}? + After sent, vendors will be notified and can submit PQ information. + </DrawerDescription> + </DrawerHeader> + <DrawerFooter className="gap-2 sm:space-x-0"> + <DrawerClose asChild> + <Button variant="outline">Cancel</Button> + </DrawerClose> + <Button + aria-label="Request selected vendors" + variant="default" + onClick={onApprove} + disabled={isApprovePending} + > + {isApprovePending && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + Request + </Button> + </DrawerFooter> + </DrawerContent> + </Drawer> + ) +}
\ No newline at end of file diff --git a/lib/vendors/table/send-vendor-dialog.tsx b/lib/vendors/table/send-vendor-dialog.tsx new file mode 100644 index 00000000..a34abb77 --- /dev/null +++ b/lib/vendors/table/send-vendor-dialog.tsx @@ -0,0 +1,150 @@ +"use client" + +import * as React from "react" +import { type Row } from "@tanstack/react-table" +import { Loader, Check, Send } from "lucide-react" +import { toast } from "sonner" + +import { useMediaQuery } from "@/hooks/use-media-query" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer" +import { Vendor } from "@/db/schema/vendors" +import { requestPQVendors, sendVendors } from "../service" + +interface ApprovalVendorDialogProps + extends React.ComponentPropsWithoutRef<typeof Dialog> { + vendors: Row<Vendor>["original"][] + showTrigger?: boolean + onSuccess?: () => void +} + +export function SendVendorsDialog({ + vendors, + showTrigger = true, + onSuccess, + ...props +}: ApprovalVendorDialogProps) { + const [isApprovePending, startApproveTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + + function onApprove() { + startApproveTransition(async () => { + const { error } = await sendVendors({ + ids: vendors.map((vendor) => vendor.id), + }) + + if (error) { + toast.error(error) + return + } + + props.onOpenChange?.(false) + toast.success("PQ successfully sent to vendors") + onSuccess?.() + }) + } + + if (isDesktop) { + return ( + <Dialog {...props}> + {showTrigger ? ( + <DialogTrigger asChild> + <Button variant="outline" size="sm" className="gap-2"> + <Send className="size-4" aria-hidden="true" /> + Send ({vendors.length}) + </Button> + </DialogTrigger> + ) : null} + <DialogContent> + <DialogHeader> + <DialogTitle>Confirm to send Vendor Information</DialogTitle> + <DialogDescription> + Are you sure you want to send{" "} + <span className="font-medium">{vendors.length}</span> + {vendors.length === 1 ? " vendor" : " vendors"}? + After vendor information is sent, vendor code will be generated. + </DialogDescription> + </DialogHeader> + <DialogFooter className="gap-2 sm:space-x-0"> + <DialogClose asChild> + <Button variant="outline">Cancel</Button> + </DialogClose> + <Button + aria-label="Send selected vendors" + variant="default" + onClick={onApprove} + disabled={isApprovePending} + > + {isApprovePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + Send + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) + } + + return ( + <Drawer {...props}> + {showTrigger ? ( + <DrawerTrigger asChild> + <Button variant="outline" size="sm" className="gap-2"> + <Send className="size-4" aria-hidden="true" /> + Send ({vendors.length}) + </Button> + </DrawerTrigger> + ) : null} + <DrawerContent> + <DrawerHeader> + <DrawerTitle>Confirm to send Vendor Information</DrawerTitle> + <DrawerDescription> + Are you sure you want to send{" "} + <span className="font-medium">{vendors.length}</span> + {vendors.length === 1 ? " vendor" : " vendors"}? + After vendor information is sent, vendor code will be generated. + </DrawerDescription> + </DrawerHeader> + <DrawerFooter className="gap-2 sm:space-x-0"> + <DrawerClose asChild> + <Button variant="outline">Cancel</Button> + </DrawerClose> + <Button + aria-label="Send selected vendors" + variant="default" + onClick={onApprove} + disabled={isApprovePending} + > + {isApprovePending && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + Send + </Button> + </DrawerFooter> + </DrawerContent> + </Drawer> + ) +}
\ No newline at end of file diff --git a/lib/vendors/table/update-vendor-sheet.tsx b/lib/vendors/table/update-vendor-sheet.tsx new file mode 100644 index 00000000..e65c4b1c --- /dev/null +++ b/lib/vendors/table/update-vendor-sheet.tsx @@ -0,0 +1,270 @@ +"use client" + +import * as React from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm } from "react-hook-form" +import { Loader } from "lucide-react" +import { toast } from "sonner" + +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" + +import { Vendor } from "@/db/schema/vendors" +import { updateVendorSchema, type UpdateVendorSchema } from "../validations" +import { modifyVendor } from "../service" +// 예: import { modifyVendor } from "@/lib/vendors/service" + +interface UpdateVendorSheetProps + extends React.ComponentPropsWithRef<typeof Sheet> { + vendor: Vendor | null +} + +// 폼 컴포넌트 +export function UpdateVendorSheet({ vendor, ...props }: UpdateVendorSheetProps) { + const [isPending, startTransition] = React.useTransition() + + console.log(vendor) + + // RHF + Zod + const form = useForm<UpdateVendorSchema>({ + resolver: zodResolver(updateVendorSchema), + defaultValues: { + vendorName: vendor?.vendorName ?? "", + vendorCode: vendor?.vendorCode ?? "", + address: vendor?.address ?? "", + country: vendor?.country ?? "", + phone: vendor?.phone ?? "", + email: vendor?.email ?? "", + website: vendor?.website ?? "", + status: vendor?.status ?? "ACTIVE", + }, + }) + + React.useEffect(() => { + if (vendor) { + form.reset({ + vendorName: vendor?.vendorName ?? "", + vendorCode: vendor?.vendorCode ?? "", + address: vendor?.address ?? "", + country: vendor?.country ?? "", + phone: vendor?.phone ?? "", + email: vendor?.email ?? "", + website: vendor?.website ?? "", + status: vendor?.status ?? "ACTIVE", + }); + } + }, [vendor, form]); + + console.log(form.getValues()) + // 제출 핸들러 + async function onSubmit(data: UpdateVendorSchema) { + if (!vendor) return + + startTransition(async () => { + // 서버 액션 or API + // const { error } = await modifyVendor({ id: vendor.id, ...data }) + // 여기선 간단 예시 + try { + // 예시: + const { error } = await modifyVendor({ id: String(vendor.id), ...data }) + if (error) throw new Error(error) + + toast.success("Vendor updated!") + form.reset() + props.onOpenChange?.(false) + } catch (err: any) { + toast.error(String(err)) + } + }) + } + + return ( + <Sheet {...props}> + <SheetContent className="flex flex-col gap-6 sm:max-w-md"> + <SheetHeader className="text-left"> + <SheetTitle>Update Vendor</SheetTitle> + <SheetDescription> + Update the vendor details and save the changes + </SheetDescription> + </SheetHeader> + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4"> + {/* vendorName */} + <FormField + control={form.control} + name="vendorName" + render={({ field }) => ( + <FormItem> + <FormLabel>Vendor Name</FormLabel> + <FormControl> + <Input placeholder="Vendor Name" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* vendorCode */} + <FormField + control={form.control} + name="vendorCode" + render={({ field }) => ( + <FormItem> + <FormLabel>Vendor Code</FormLabel> + <FormControl> + <Input placeholder="Code123" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* address */} + <FormField + control={form.control} + name="address" + render={({ field }) => ( + <FormItem> + <FormLabel>Address</FormLabel> + <FormControl> + <Input placeholder="123 Main St" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* country */} + <FormField + control={form.control} + name="country" + render={({ field }) => ( + <FormItem> + <FormLabel>Country</FormLabel> + <FormControl> + <Input placeholder="USA" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* phone */} + <FormField + control={form.control} + name="phone" + render={({ field }) => ( + <FormItem> + <FormLabel>Phone</FormLabel> + <FormControl> + <Input placeholder="+1 555-1234" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* email */} + <FormField + control={form.control} + name="email" + render={({ field }) => ( + <FormItem> + <FormLabel>Email</FormLabel> + <FormControl> + <Input placeholder="vendor@example.com" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* website */} + <FormField + control={form.control} + name="website" + render={({ field }) => ( + <FormItem> + <FormLabel>Website</FormLabel> + <FormControl> + <Input placeholder="https://www.vendor.com" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* status */} + <FormField + control={form.control} + name="status" + render={({ field }) => ( + <FormItem> + <FormLabel>Status</FormLabel> + <FormControl> + <Select + value={field.value} + onValueChange={field.onChange} + > + <SelectTrigger className="capitalize"> + <SelectValue placeholder="Select a status" /> + </SelectTrigger> + <SelectContent> + <SelectGroup> + {/* enum ["ACTIVE","INACTIVE","BLACKLISTED"] */} + <SelectItem value="ACTIVE">ACTIVE</SelectItem> + <SelectItem value="INACTIVE">INACTIVE</SelectItem> + <SelectItem value="BLACKLISTED">BLACKLISTED</SelectItem> + </SelectGroup> + </SelectContent> + </Select> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <SheetFooter className="gap-2 pt-2 sm:space-x-0"> + <SheetClose asChild> + <Button type="button" variant="outline"> + Cancel + </Button> + </SheetClose> + <Button disabled={isPending}> + {isPending && ( + <Loader className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" /> + )} + Save + </Button> + </SheetFooter> + </form> + </Form> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/lib/vendors/table/vendors-table-columns.tsx b/lib/vendors/table/vendors-table-columns.tsx new file mode 100644 index 00000000..c503e369 --- /dev/null +++ b/lib/vendors/table/vendors-table-columns.tsx @@ -0,0 +1,279 @@ +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { Ellipsis, PaperclipIcon } from "lucide-react" +import { toast } from "sonner" + +import { getErrorMessage } from "@/lib/handle-error" +import { formatDate } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header" +import { useRouter } from "next/navigation" + +import { Vendor, vendors, VendorWithAttachments } from "@/db/schema/vendors" +import { modifyVendor } from "../service" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { vendorColumnsConfig } from "@/config/vendorColumnsConfig" +import { Separator } from "@/components/ui/separator" +import { AttachmentsButton } from "./attachmentButton" + + +type NextRouter = ReturnType<typeof useRouter>; + + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<Vendor> | null>>; + router: NextRouter; +} + +/** + * tanstack table 컬럼 정의 (중첩 헤더 버전) + */ +export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef<Vendor>[] { + // ---------------------------------------------------------------- + // 1) select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef<Vendor> = { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + } + + // ---------------------------------------------------------------- + // 2) actions 컬럼 (Dropdown 메뉴) + // ---------------------------------------------------------------- + const actionsColumn: ColumnDef<Vendor> = { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-8 p-0 data-[state=open]:bg-muted" + > + <Ellipsis className="size-4" aria-hidden="true" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-40"> + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "update" })} + > + Edit + </DropdownMenuItem> + <DropdownMenuItem + onSelect={() => { + // 1) 만약 rowAction을 열고 싶다면 + // setRowAction({ row, type: "update" }) + + // 2) 자세히 보기 페이지로 클라이언트 라우팅 + router.push(`/evcp/vendors/${row.original.id}/info`); + }} + > + Details + </DropdownMenuItem> + <Separator /> + <DropdownMenuSub> + <DropdownMenuSubTrigger>Status</DropdownMenuSubTrigger> + <DropdownMenuSubContent> + <DropdownMenuRadioGroup + value={row.original.status} + onValueChange={(value) => { + startUpdateTransition(() => { + toast.promise( + modifyVendor({ + id: String(row.original.id), + status: value as Vendor["status"], + }), + { + loading: "Updating...", + success: "Label updated", + error: (err) => getErrorMessage(err), + } + ) + }) + }} + > + {vendors.status.enumValues.map((status) => ( + <DropdownMenuRadioItem + key={status} + value={status} + className="capitalize" + disabled={isUpdatePending} + > + {status} + </DropdownMenuRadioItem> + ))} + </DropdownMenuRadioGroup> + </DropdownMenuSubContent> + </DropdownMenuSub> + + </DropdownMenuContent> + </DropdownMenu> + ) + }, + size: 40, + } + + // ---------------------------------------------------------------- + // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 + // ---------------------------------------------------------------- + // 3-1) groupMap: { [groupName]: ColumnDef<Vendor>[] } + const groupMap: Record<string, ColumnDef<Vendor>[]> = {} + + vendorColumnsConfig.forEach((cfg) => { + // 만약 group가 없으면 "_noGroup" 처리 + const groupName = cfg.group || "_noGroup" + + if (!groupMap[groupName]) { + groupMap[groupName] = [] + } + + // child column 정의 + const childCol: ColumnDef<Vendor> = { + accessorKey: cfg.id, + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title={cfg.label} /> + ), + meta: { + excelHeader: cfg.excelHeader, + group: cfg.group, + type: cfg.type, + }, + cell: ({ row, cell }) => { + + + if (cfg.id === "status") { + const statusVal = row.original.status + if (!statusVal) return null + // const Icon = getStatusIcon(statusVal) + return ( + <div className="flex w-[6.25rem] items-center"> + {/* <Icon className="mr-2 size-4 text-muted-foreground" aria-hidden="true" /> */} + <span className="capitalize">{statusVal}</span> + </div> + ) + } + + + if (cfg.id === "createdAt") { + const dateVal = cell.getValue() as Date + return formatDate(dateVal) + } + + if (cfg.id === "updatedAt") { + const dateVal = cell.getValue() as Date + return formatDate(dateVal) + } + + + // code etc... + return row.getValue(cfg.id) ?? "" + }, + } + + groupMap[groupName].push(childCol) + }) + + // ---------------------------------------------------------------- + // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기 + // ---------------------------------------------------------------- + const nestedColumns: ColumnDef<Vendor>[] = [] + + // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함 + // 여기서는 그냥 Object.entries 순서 + Object.entries(groupMap).forEach(([groupName, colDefs]) => { + if (groupName === "_noGroup") { + // 그룹 없음 → 그냥 최상위 레벨 컬럼 + nestedColumns.push(...colDefs) + } else { + // 상위 컬럼 + nestedColumns.push({ + id: groupName, + header: groupName, // "Basic Info", "Metadata" 등 + columns: colDefs, + }) + } + }) + + const attachmentsColumn: ColumnDef<VendorWithAttachments> = { + id: "attachments", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="" /> + ), + cell: ({ row }) => { + // hasAttachments 및 attachmentsList 속성이 추가되었다고 가정 + const hasAttachments = row.original.hasAttachments; + const attachmentsList = row.original.attachmentsList || []; + + if(hasAttachments){ + + // 서버 액션을 사용하는 컴포넌트로 교체 + return ( + <AttachmentsButton + vendorId={row.original.id} + hasAttachments={hasAttachments} + attachmentsList={attachmentsList} + /> + );}{ + return null + } + }, + enableSorting: false, + enableHiding: false, + minSize: 45, + }; + + + // ---------------------------------------------------------------- + // 4) 최종 컬럼 배열: select, nestedColumns, actions + // ---------------------------------------------------------------- + return [ + selectColumn, + attachmentsColumn, + ...nestedColumns, + actionsColumn, + ] +}
\ No newline at end of file diff --git a/lib/vendors/table/vendors-table-floating-bar.tsx b/lib/vendors/table/vendors-table-floating-bar.tsx new file mode 100644 index 00000000..791fb760 --- /dev/null +++ b/lib/vendors/table/vendors-table-floating-bar.tsx @@ -0,0 +1,241 @@ +"use client" + +import * as React from "react" +import { SelectTrigger } from "@radix-ui/react-select" +import { type Table } from "@tanstack/react-table" +import { + ArrowUp, + CheckCircle2, + Download, + Loader, + Trash2, + X, +} from "lucide-react" +import { toast } from "sonner" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" +import { Portal } from "@/components/ui/portal" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, +} from "@/components/ui/select" +import { Separator } from "@/components/ui/separator" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" +import { Kbd } from "@/components/kbd" + +import { ActionConfirmDialog } from "@/components/ui/action-dialog" +import { Vendor, vendors } from "@/db/schema/vendors" +import { modifyVendors } from "../service" + +interface VendorsTableFloatingBarProps { + table: Table<Vendor> +} + + +export function VendorsTableFloatingBar({ table }: VendorsTableFloatingBarProps) { + const rows = table.getFilteredSelectedRowModel().rows + + const [isPending, startTransition] = React.useTransition() + const [action, setAction] = React.useState< + "update-status" | "export" | "delete" + >() + const [popoverOpen, setPopoverOpen] = React.useState(false) + + // Clear selection on Escape key press + React.useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + table.toggleAllRowsSelected(false) + } + } + + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [table]) + + + + // 공용 confirm dialog state + const [confirmDialogOpen, setConfirmDialogOpen] = React.useState(false) + const [confirmProps, setConfirmProps] = React.useState<{ + title: string + description?: string + onConfirm: () => Promise<void> | void + }>({ + title: "", + description: "", + onConfirm: () => { }, + }) + + + // 2) + function handleSelectStatus(newStatus: Vendor["status"]) { + setAction("update-status") + + setConfirmProps({ + title: `Update ${rows.length} vendor${rows.length > 1 ? "s" : ""} with status: ${newStatus}?`, + description: "This action will override their current status.", + onConfirm: async () => { + startTransition(async () => { + const { error } = await modifyVendors({ + ids: rows.map((row) => String(row.original.id)), + status: newStatus, + }) + if (error) { + toast.error(error) + return + } + toast.success("Vendors updated") + setConfirmDialogOpen(false) + }) + }, + }) + setConfirmDialogOpen(true) + } + + + return ( + <Portal > + <div className="fixed inset-x-0 bottom-10 z-50 mx-auto w-fit px-2.5" style={{ bottom: '1.5rem' }}> + <div className="w-full overflow-x-auto"> + <div className="mx-auto flex w-fit items-center gap-2 rounded-md border bg-background p-2 text-foreground shadow"> + <div className="flex h-7 items-center rounded-md border border-dashed pl-2.5 pr-1"> + <span className="whitespace-nowrap text-xs"> + {rows.length} selected + </span> + <Separator orientation="vertical" className="ml-2 mr-1" /> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="icon" + className="size-5 hover:border" + onClick={() => table.toggleAllRowsSelected(false)} + > + <X className="size-3.5 shrink-0" aria-hidden="true" /> + </Button> + </TooltipTrigger> + <TooltipContent className="flex items-center border bg-accent px-2 py-1 font-semibold text-foreground dark:bg-zinc-900"> + <p className="mr-2">Clear selection</p> + <Kbd abbrTitle="Escape" variant="outline"> + Esc + </Kbd> + </TooltipContent> + </Tooltip> + </div> + <Separator orientation="vertical" className="hidden h-5 sm:block" /> + <div className="flex items-center gap-1.5"> + <Select + onValueChange={(value: Vendor["status"]) => { + handleSelectStatus(value) + }} + > + <Tooltip> + <SelectTrigger asChild> + <TooltipTrigger asChild> + <Button + variant="secondary" + size="icon" + className="size-7 border data-[state=open]:bg-accent data-[state=open]:text-accent-foreground" + disabled={isPending} + > + {isPending && action === "update-status" ? ( + <Loader + className="size-3.5 animate-spin" + aria-hidden="true" + /> + ) : ( + <CheckCircle2 + className="size-3.5" + aria-hidden="true" + /> + )} + </Button> + </TooltipTrigger> + </SelectTrigger> + <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> + <p>Update status</p> + </TooltipContent> + </Tooltip> + <SelectContent align="center"> + <SelectGroup> + {vendors.status.enumValues.map((status) => ( + <SelectItem + key={status} + value={status} + className="capitalize" + > + {status} + </SelectItem> + ))} + </SelectGroup> + </SelectContent> + </Select> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="secondary" + size="icon" + className="size-7 border" + onClick={() => { + setAction("export") + + startTransition(() => { + exportTableToExcel(table, { + excludeColumns: ["select", "actions"], + onlySelected: true, + }) + }) + }} + disabled={isPending} + > + {isPending && action === "export" ? ( + <Loader + className="size-3.5 animate-spin" + aria-hidden="true" + /> + ) : ( + <Download className="size-3.5" aria-hidden="true" /> + )} + </Button> + </TooltipTrigger> + <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> + <p>Export vendors</p> + </TooltipContent> + </Tooltip> + + </div> + </div> + </div> + </div> + + + {/* 공용 Confirm Dialog */} + <ActionConfirmDialog + open={confirmDialogOpen} + onOpenChange={setConfirmDialogOpen} + title={confirmProps.title} + description={confirmProps.description} + onConfirm={confirmProps.onConfirm} + isLoading={isPending && (action === "delete" || action === "update-status")} + confirmLabel={ + action === "delete" + ? "Delete" + : action === "update-status" + ? "Update" + : "Confirm" + } + confirmVariant={ + action === "delete" ? "destructive" : "default" + } + /> + </Portal> + ) +} diff --git a/lib/vendors/table/vendors-table-toolbar-actions.tsx b/lib/vendors/table/vendors-table-toolbar-actions.tsx new file mode 100644 index 00000000..c0605191 --- /dev/null +++ b/lib/vendors/table/vendors-table-toolbar-actions.tsx @@ -0,0 +1,97 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Download, Upload, Check } from "lucide-react" +import { toast } from "sonner" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" +import { Vendor } from "@/db/schema/vendors" +import { ApproveVendorsDialog } from "./approve-vendor-dialog" +import { RequestPQVendorsDialog } from "./request-vendor-pg-dialog" +import { SendVendorsDialog } from "./send-vendor-dialog" + +interface VendorsTableToolbarActionsProps { + table: Table<Vendor> +} + +export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActionsProps) { + // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식 + const fileInputRef = React.useRef<HTMLInputElement>(null) + + // 선택된 벤더 중 PENDING_REVIEW 상태인 벤더만 필터링 + const pendingReviewVendors = React.useMemo(() => { + return table + .getFilteredSelectedRowModel() + .rows + .map(row => row.original) + .filter(vendor => vendor.status === "PENDING_REVIEW"); + }, [table.getFilteredSelectedRowModel().rows]); + + + // 선택된 벤더 중 PENDING_REVIEW 상태인 벤더만 필터링 + const inReviewVendors = React.useMemo(() => { + return table + .getFilteredSelectedRowModel() + .rows + .map(row => row.original) + .filter(vendor => vendor.status === "IN_REVIEW"); + }, [table.getFilteredSelectedRowModel().rows]); + + const approvedVendors = React.useMemo(() => { + return table + .getFilteredSelectedRowModel() + .rows + .map(row => row.original) + .filter(vendor => vendor.status === "APPROVED"); + }, [table.getFilteredSelectedRowModel().rows]); + + + + return ( + <div className="flex items-center gap-2"> + + + + {/* 승인 다이얼로그: PENDING_REVIEW 상태인 벤더가 있을 때만 표시 */} + {pendingReviewVendors.length > 0 && ( + <ApproveVendorsDialog + vendors={pendingReviewVendors} + onSuccess={() => table.toggleAllRowsSelected(false)} + /> + )} + + {inReviewVendors.length > 0 && ( + <RequestPQVendorsDialog + vendors={inReviewVendors} + onSuccess={() => table.toggleAllRowsSelected(false)} + /> + )} + + {approvedVendors.length > 0 && ( + <SendVendorsDialog + vendors={approvedVendors} + onSuccess={() => table.toggleAllRowsSelected(false)} + /> + )} + + + {/** 4) Export 버튼 */} + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(table, { + filename: "vendors", + excludeColumns: ["select", "actions"], + }) + } + className="gap-2" + > + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Export</span> + </Button> + </div> + ) +}
\ No newline at end of file diff --git a/lib/vendors/table/vendors-table.tsx b/lib/vendors/table/vendors-table.tsx new file mode 100644 index 00000000..c04d57a9 --- /dev/null +++ b/lib/vendors/table/vendors-table.tsx @@ -0,0 +1,121 @@ +"use client" + +import * as React from "react" +import { useRouter } from "next/navigation" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" + +import { toSentenceCase } from "@/lib/utils" +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import { useFeatureFlags } from "./feature-flags-provider" +import { getColumns } from "./vendors-table-columns" +import { getVendors, getVendorStatusCounts } from "../service" +import { Vendor, vendors } from "@/db/schema/vendors" +import { VendorsTableToolbarActions } from "./vendors-table-toolbar-actions" +import { VendorsTableFloatingBar } from "./vendors-table-floating-bar" +import { UpdateTaskSheet } from "@/lib/tasks/table/update-task-sheet" +import { UpdateVendorSheet } from "./update-vendor-sheet" + +interface VendorsTableProps { + promises: Promise< + [ + Awaited<ReturnType<typeof getVendors>>, + Awaited<ReturnType<typeof getVendorStatusCounts>> + ] + > +} + +export function VendorsTable({ promises }: VendorsTableProps) { + const { featureFlags } = useFeatureFlags() + + // Suspense로 받아온 데이터 + const [{ data, pageCount }, statusCounts] = React.use(promises) + + const [rowAction, setRowAction] = React.useState<DataTableRowAction<Vendor> | null>(null) + + // **router** 획득 + const router = useRouter() + + // getColumns() 호출 시, router를 주입 + const columns = React.useMemo( + () => getColumns({ setRowAction, router }), + [setRowAction, router] + ) + + const filterFields: DataTableFilterField<Vendor>[] = [ + { + id: "status", + label: "Status", + options: vendors.status.enumValues.map((status) => ({ + label: toSentenceCase(status), + value: status, + count: statusCounts[status], + })), + }, + + { id: "vendorCode", label: "Vendor Code" }, + + ] + + const advancedFilterFields: DataTableAdvancedFilterField<Vendor>[] = [ + { id: "vendorName", label: "Vendor Name", type: "text" }, + { id: "vendorCode", label: "Vendor Code", type: "text" }, + { id: "email", label: "Email", type: "text" }, + { id: "country", label: "Country", type: "text" }, + { + id: "status", + label: "Status", + type: "multi-select", + options: vendors.status.enumValues.map((status) => ({ + label: toSentenceCase(status), + value: status, + count: statusCounts[status], + })), + }, + { id: "createdAt", label: "Created at", type: "date" }, + { id: "updatedAt", label: "Updated at", type: "date" }, + ] + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + <DataTable + table={table} + // floatingBar={<VendorsTableFloatingBar table={table} />} + > + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <VendorsTableToolbarActions table={table} /> + </DataTableAdvancedToolbar> + </DataTable> + <UpdateVendorSheet + open={rowAction?.type === "update"} + onOpenChange={() => setRowAction(null)} + vendor={rowAction?.row.original ?? null} + /> + </> + ) +}
\ No newline at end of file diff --git a/lib/vendors/validations.ts b/lib/vendors/validations.ts new file mode 100644 index 00000000..14efc8dc --- /dev/null +++ b/lib/vendors/validations.ts @@ -0,0 +1,341 @@ +import { tasks, type Task } from "@/db/schema/tasks"; +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, +} from "nuqs/server" +import * as z from "zod" + +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" +import { Vendor, VendorContact, VendorItemsView, vendors } from "@/db/schema/vendors"; +import { rfqs } from "@/db/schema/rfq" + + +export const searchParamsCache = createSearchParamsCache({ + + // 공통 플래그 + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault( + [] + ), + + // 페이징 + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + + // 정렬 (vendors 테이블에 맞춰 Vendor 타입 지정) + sort: getSortingStateParser<Vendor>().withDefault([ + { id: "createdAt", desc: true }, // createdAt 기준 내림차순 + ]), + + // 고급 필터 + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + + // 검색 키워드 + search: parseAsString.withDefault(""), + + // ----------------------------------------------------------------- + // 여기부터는 "벤더"에 특화된 검색 필드 예시 + // ----------------------------------------------------------------- + // 상태 (ACTIVE, INACTIVE, BLACKLISTED 등) 중에서 선택 + status: parseAsStringEnum(["ACTIVE", "INACTIVE", "BLACKLISTED"]), + + // 벤더명 검색 + vendorName: parseAsString.withDefault(""), + + // 국가 검색 + country: parseAsString.withDefault(""), + + // 예) 코드 검색 + vendorCode: parseAsString.withDefault(""), + + // 필요하다면 이메일 검색 / 웹사이트 검색 등 추가 가능 + email: parseAsString.withDefault(""), + website: parseAsString.withDefault(""), +}); + +export const searchParamsContactCache = createSearchParamsCache({ + // 공통 플래그 + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault( + [] + ), + + // 페이징 + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + + // 정렬 (vendors 테이블에 맞춰 Vendor 타입 지정) + sort: getSortingStateParser<VendorContact>().withDefault([ + { id: "createdAt", desc: true }, // createdAt 기준 내림차순 + ]), + + // 고급 필터 + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + + // 검색 키워드 + search: parseAsString.withDefault(""), + + + contactName: parseAsString.withDefault(""), + contactPosition: parseAsString.withDefault(""), + contactEmail: parseAsString.withDefault(""), + contactPhone: parseAsString.withDefault(""), +}); + + + +export const searchParamsItemCache = createSearchParamsCache({ + // 공통 플래그 + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault( + [] + ), + + // 페이징 + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + + // 정렬 (vendors 테이블에 맞춰 Vendor 타입 지정) + sort: getSortingStateParser<VendorItemsView>().withDefault([ + { id: "createdAt", desc: true }, // createdAt 기준 내림차순 + ]), + + // 고급 필터 + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + + // 검색 키워드 + search: parseAsString.withDefault(""), + + + itemName: parseAsString.withDefault(""), + itemCode: parseAsString.withDefault(""), + description: parseAsString.withDefault(""), +}); + + +export const updateVendorSchema = z.object({ + vendorName: z.string().min(1, "Vendor name is required").max(255, "Max length 255").optional(), + vendorCode: z.string().max(100, "Max length 100").optional(), + address: z.string().optional(), + country: z.string().max(100, "Max length 100").optional(), + phone: z.string().max(50, "Max length 50").optional(), + email: z.string().email("Invalid email").max(255).optional(), + website: z.string().url("Invalid URL").max(255).optional(), + + // status는 특정 값만 허용하도록 enum 사용 예시 + // 필요 시 'SUSPENDED', 'BLACKLISTED' 등 추가하거나 제거 가능 + status: z.enum(vendors.status.enumValues) + .optional() + .default("ACTIVE"), +}); + + +const contactSchema = z.object({ + contactName: z + .string() + .min(1, "Contact name is required") + .max(255, "Max length 255"), + contactPosition: z.string().max(100).optional(), + contactEmail: z.string().email("Invalid email").max(255), + contactPhone: z.string().max(50).optional(), + isPrimary: z.boolean().default(false).optional()}) + +const vendorStatusEnum = z.enum(vendors.status.enumValues) +// CREATE 시: 일부 필드는 필수, 일부는 optional +export const createVendorSchema = z + .object({ + + vendorName: z + .string() + .min(1, "Vendor name is required") + .max(255, "Max length 255"), + email: z.string().email("Invalid email").max(255), + taxId: z.string().max(100, "Max length 100"), + + // 나머지 optional + vendorCode: z.string().max(100, "Max length 100").optional(), + address: z.string().optional(), + country: z.string() + .min(1, "국가 선택은 필수입니다.") + .max(100, "Max length 100"), + phone: z.string().max(50, "Max length 50").optional(), + website: z.string().url("Invalid URL").max(255).optional(), + + creditRatingAttachment: z.any().optional(), // 신용평가 첨부 + cashFlowRatingAttachment: z.any().optional(), // 현금흐름 첨부 + attachedFiles: z.any() + .refine( + val => { + // Validate that files exist and there's at least one file + return val && + (Array.isArray(val) ? val.length > 0 : + val instanceof FileList ? val.length > 0 : + val && typeof val === 'object' && 'length' in val && val.length > 0); + }, + { message: "첨부 파일은 필수입니다." } + ), + status: vendorStatusEnum.default("PENDING_REVIEW"), + + representativeName: z.union([z.string().max(255), z.literal("")]).optional(), + representativeBirth: z.union([z.string().max(20), z.literal("")]).optional(), + representativeEmail: z.union([z.string().email("Invalid email").max(255), z.literal("")]).optional(), + representativePhone: z.union([z.string().max(50), z.literal("")]).optional(), + corporateRegistrationNumber: z.union([z.string().max(100), z.literal("")]).optional(), + + creditAgency: z.string().max(50).optional(), + creditRating: z.string().max(50).optional(), + cashFlowRating: z.string().max(50).optional(), + + contacts: z + .array(contactSchema) + .nonempty("At least one contact is required."), + + // ... (기타 필드) + }) + .superRefine((data, ctx) => { + if (data.country === "KR") { + // 1) 대표자 정보가 누락되면 각각 에러 발생 + if (!data.representativeName) { + ctx.addIssue({ + code: "custom", + path: ["representativeName"], + message: "대표자 이름은 한국(KR) 업체일 경우 필수입니다.", + }) + } + if (!data.representativeBirth) { + ctx.addIssue({ + code: "custom", + path: ["representativeBirth"], + message: "대표자 생년월일은 한국(KR) 업체일 경우 필수입니다.", + }) + } + if (!data.representativeEmail) { + ctx.addIssue({ + code: "custom", + path: ["representativeEmail"], + message: "대표자 이메일은 한국(KR) 업체일 경우 필수입니다.", + }) + } + if (!data.representativePhone) { + ctx.addIssue({ + code: "custom", + path: ["representativePhone"], + message: "대표자 전화번호는 한국(KR) 업체일 경우 필수입니다.", + }) + } + if (!data.corporateRegistrationNumber) { + ctx.addIssue({ + code: "custom", + path: ["corporateRegistrationNumber"], + message: "법인등록번호는 한국(KR) 업체일 경우 필수입니다.", + }) + } + + // 2) 신용/현금흐름 등급도 필수라면 + if (!data.creditAgency) { + ctx.addIssue({ + code: "custom", + path: ["creditAgency"], + message: "신용평가사 선택은 한국(KR) 업체일 경우 필수입니다.", + }) + } + if (!data.creditRating) { + ctx.addIssue({ + code: "custom", + path: ["creditRating"], + message: "신용평가등급은 한국(KR) 업체일 경우 필수입니다.", + }) + } + if (!data.cashFlowRating) { + ctx.addIssue({ + code: "custom", + path: ["cashFlowRating"], + message: "현금흐름등급은 한국(KR) 업체일 경우 필수입니다.", + }) + } + } + } +) + +export const createVendorContactSchema = z.object({ + vendorId: z.number(), + contactName: z.string() + .min(1, "Contact name is required") + .max(255, "Max length 255"), // 신규 생성 시 반드시 입력 + contactPosition: z.string().max(100, "Max length 100"), + contactEmail: z.string().email(), + contactPhone: z.string().max(50, "Max length 50").optional(), + isPrimary: z.boolean(), +}); + + +export const updateVendorContactSchema = z.object({ + contactName: z.string() + .min(1, "Contact name is required") + .max(255, "Max length 255"), // 신규 생성 시 반드시 입력 + contactPosition: z.string().max(100, "Max length 100").optional(), + contactEmail: z.string().email().optional(), + contactPhone: z.string().max(50, "Max length 50").optional(), + isPrimary: z.boolean().optional(), +}); + + + +export const createVendorItemSchema = z.object({ + vendorId: z.number(), + itemCode: z.string().max(100, "Max length 100"), + +}); + + +export const updateVendorItemSchema = z.object({ + itemName: z.string().optional(), + itemCode: z.string().max(100, "Max length 100"), + description: z.string().optional() +}); + +export const searchParamsRfqHistoryCache = createSearchParamsCache({ + // 공통 플래그 + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault( + [] + ), + + // 페이징 + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + + // 정렬 + sort: getSortingStateParser<typeof rfqs.$inferSelect>().withDefault([ + { id: "createdAt", desc: true }, + ]), + + // 고급 필터 + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + + // 검색 키워드 + search: parseAsString.withDefault(""), + + // RFQ 특화 필터 + rfqCode: parseAsString.withDefault(""), + projectCode: parseAsString.withDefault(""), + projectName: parseAsString.withDefault(""), + status: parseAsStringEnum(["DRAFT", "IN_PROGRESS", "COMPLETED", "CANCELLED"]), + vendorStatus: parseAsStringEnum(["INVITED", "ACCEPTED", "DECLINED", "SUBMITTED", "AWARDED", "REJECTED"]), + dueDate: parseAsString.withDefault(""), +}); + +export type GetVendorsSchema = Awaited<ReturnType<typeof searchParamsCache.parse>> +export type GetVendorContactsSchema = Awaited<ReturnType<typeof searchParamsContactCache.parse>> +export type GetVendorItemsSchema = Awaited<ReturnType<typeof searchParamsItemCache.parse>> + +export type UpdateVendorSchema = z.infer<typeof updateVendorSchema> +export type CreateVendorSchema = z.infer<typeof createVendorSchema> +export type CreateVendorContactSchema = z.infer<typeof createVendorContactSchema> +export type UpdateVendorContactSchema = z.infer<typeof updateVendorContactSchema> +export type CreateVendorItemSchema = z.infer<typeof createVendorItemSchema> +export type UpdateVendorItemSchema = z.infer<typeof updateVendorItemSchema> +export type GetRfqHistorySchema = Awaited<ReturnType<typeof searchParamsRfqHistoryCache.parse>> |
