diff options
| author | joonhoekim <26rote@gmail.com> | 2025-03-25 15:55:45 +0900 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-03-25 15:55:45 +0900 |
| commit | 1a2241c40e10193c5ff7008a7b7b36cc1d855d96 (patch) | |
| tree | 8a5587f10ca55b162d7e3254cb088b323a34c41b /lib/po | |
initial commit
Diffstat (limited to 'lib/po')
| -rw-r--r-- | lib/po/repository.ts | 44 | ||||
| -rw-r--r-- | lib/po/service.ts | 431 | ||||
| -rw-r--r-- | lib/po/service_r1.ts | 282 | ||||
| -rw-r--r-- | lib/po/table/feature-flags-provider.tsx | 108 | ||||
| -rw-r--r-- | lib/po/table/po-table-columns.tsx | 155 | ||||
| -rw-r--r-- | lib/po/table/po-table-toolbar-actions.tsx | 53 | ||||
| -rw-r--r-- | lib/po/table/po-table.tsx | 164 | ||||
| -rw-r--r-- | lib/po/table/sign-request-dialog.tsx | 410 | ||||
| -rw-r--r-- | lib/po/validations.ts | 67 |
9 files changed, 1714 insertions, 0 deletions
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 |
