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/tasks | |
initial commit
Diffstat (limited to 'lib/tasks')
| -rw-r--r-- | lib/tasks/repository.ts | 166 | ||||
| -rw-r--r-- | lib/tasks/service.ts | 561 | ||||
| -rw-r--r-- | lib/tasks/table/add-tasks-dialog.tsx | 227 | ||||
| -rw-r--r-- | lib/tasks/table/delete-tasks-dialog.tsx | 149 | ||||
| -rw-r--r-- | lib/tasks/table/feature-flags-provider.tsx | 108 | ||||
| -rw-r--r-- | lib/tasks/table/feature-flags.tsx | 96 | ||||
| -rw-r--r-- | lib/tasks/table/tasks-table-columns.tsx | 262 | ||||
| -rw-r--r-- | lib/tasks/table/tasks-table-floating-bar.tsx | 354 | ||||
| -rw-r--r-- | lib/tasks/table/tasks-table-toolbar-actions.tsx | 117 | ||||
| -rw-r--r-- | lib/tasks/table/tasks-table.tsx | 197 | ||||
| -rw-r--r-- | lib/tasks/table/update-task-sheet.tsx | 230 | ||||
| -rw-r--r-- | lib/tasks/utils.ts | 80 | ||||
| -rw-r--r-- | lib/tasks/validations.ts | 50 |
13 files changed, 2597 insertions, 0 deletions
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> |
