diff options
Diffstat (limited to 'lib/evaluation-criteria/table')
5 files changed, 1447 insertions, 0 deletions
diff --git a/lib/evaluation-criteria/table/reg-eval-criteria-columns.tsx b/lib/evaluation-criteria/table/reg-eval-criteria-columns.tsx new file mode 100644 index 00000000..7367fabb --- /dev/null +++ b/lib/evaluation-criteria/table/reg-eval-criteria-columns.tsx @@ -0,0 +1,364 @@ +'use client'; + +/* IMPORT */ +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { DataTableColumnHeaderSimple } from '@/components/data-table/data-table-column-simple-header'; +import { Dispatch, SetStateAction } from 'react'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { PenToolIcon, TrashIcon } from 'lucide-react'; +import { + REG_EVAL_CRITERIA_CATEGORY, + REG_EVAL_CRITERIA_ITEM, + REG_EVAL_CRITERIA_CATEGORY2, + type RegEvalCriteriaView, +} from '@/db/schema'; +import { type ColumnDef } from '@tanstack/react-table'; +import { type DataTableRowAction } from '@/types/table'; + +// ---------------------------------------------------------------------------------------------------- + +/* TYPES */ +interface GetColumnsProps { + setRowAction: Dispatch<SetStateAction<DataTableRowAction<RegEvalCriteriaView> | null>>, +}; + +// ---------------------------------------------------------------------------------------------------- + +/* FUNCTION FOR GETTING COLUMNS SETTING */ +function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<RegEvalCriteriaView>[] { + + // [1] SELECT COLUMN - CHECKBOX + const selectColumn: ColumnDef<RegEvalCriteriaView> = { + id: 'select', + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && 'indeterminate') + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="select-all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="select-row" + className="translate-y-0.5" + /> + ), + enableSorting: false, + enableHiding: false, + size:40, + }; + + // [2] CRITERIA COLUMNS + const criteriaColumns: ColumnDef<RegEvalCriteriaView>[] = [ + { + accessorKey: 'category', + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="평가부문" /> + ), + cell: ({ row }) => { + const value = row.getValue<string>('category'); + const label = REG_EVAL_CRITERIA_CATEGORY.find(item => item.value === value)?.label ?? value; + return ( + <Badge variant="default"> + {label} + </Badge> + ); + }, + enableSorting: true, + enableHiding: false, + meta: { + excelHeader: 'Category', + type: 'select', + }, + }, + { + accessorKey: 'scoreCategory', + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="점수구분" /> + ), + cell: ({ row }) => { + const value = row.getValue<string>('scoreCategory'); + const label = REG_EVAL_CRITERIA_CATEGORY2.find(item => item.value === value)?.label ?? value; + return ( + <Badge variant="secondary"> + {label} + </Badge> + ); + }, + enableSorting: true, + enableHiding: false, + meta: { + excelHeader: 'Score Category', + type: 'select', + }, + }, + { + accessorKey: 'item', + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="항목" /> + ), + cell: ({ row }) => { + const value = row.getValue<string>('item'); + const label = REG_EVAL_CRITERIA_ITEM.find(item => item.value === value)?.label ?? value; + return ( + <Badge variant="outline"> + {label} + </Badge> + ); + }, + enableSorting: true, + enableHiding: false, + meta: { + excelHeader: 'Item', + type: 'select', + }, + }, + { + accessorKey: 'classification', + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="구분" /> + ), + cell: ({ row }) => ( + <div className="font-regular"> + {row.getValue('classification')} + </div> + ), + enableSorting: true, + enableHiding: false, + meta: { + excelHeader: 'Classification', + type: 'text', + }, + }, + { + accessorKey: 'range', + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="범위" /> + ), + cell: ({ row }) => ( + <div className="font-regular"> + {row.getValue('range') || '-'} + </div> + ), + enableSorting: true, + enableHiding: false, + meta: { + excelHeader: 'Range', + type: 'text', + }, + }, + { + accessorKey: 'detail', + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="평가내용" /> + ), + cell: ({ row }) => ( + <div className="font-bold"> + {row.getValue('detail')} + </div> + ), + enableSorting: true, + enableHiding: false, + meta: { + excelHeader: 'Detail', + type: 'text', + }, + }, + ]; + + // [3] SCORE COLUMNS + const scoreEquipColumns: ColumnDef<RegEvalCriteriaView>[] = [ + { + accessorKey: 'scoreEquipShip', + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="조선" /> + ), + cell: ({ row }) => { + const value = row.getValue<string>('scoreEquipShip'); + const displayValue = typeof value === 'string' + ? parseFloat(parseFloat(value).toFixed(2)).toString() + : '-'; + return ( + <div className="font-bold"> + {displayValue} + </div> + ); + }, + enableSorting: true, + enableHiding: false, + meta: { + excelHeader: 'Equipment-Shipbuilding Score', + group: 'Equipment Score', + type: 'number', + }, + }, + { + accessorKey: 'scoreEquipMarine', + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="해양" /> + ), + cell: ({ row }) => { + const value = row.getValue<string>('scoreEquipMarine'); + const displayValue = typeof value === 'string' + ? parseFloat(parseFloat(value).toFixed(2)).toString() + : '-'; + return ( + <div className="font-bold"> + {displayValue} + </div> + ); + }, + enableSorting: true, + enableHiding: false, + meta: { + excelHeader: 'Equipment-Marine Engineering Score', + group: 'Equipment Score', + type: 'number', + }, + }, + ]; + const scoreBulkColumns: ColumnDef<RegEvalCriteriaView>[] = [ + { + accessorKey: 'scoreBulkShip', + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="조선" /> + ), + cell: ({ row }) => { + const value = row.getValue<string>('scoreBulkShip'); + const displayValue = typeof value === 'string' + ? parseFloat(parseFloat(value).toFixed(2)).toString() + : '-'; + return ( + <div className="font-bold"> + {displayValue} + </div> + ); + }, + enableSorting: true, + enableHiding: false, + meta: { + excelHeader: 'Bulk-Shipbuiling Score', + group: 'Bulk Score', + type: 'number', + }, + }, + { + accessorKey: 'scoreBulkMarine', + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="해양" /> + ), + cell: ({ row }) => { + const value = row.getValue<string>('scoreBulkMarine'); + const displayValue = typeof value === 'string' + ? parseFloat(parseFloat(value).toFixed(2)).toString() + : '-'; + return ( + <div className="font-bold"> + {displayValue} + </div> + ); + }, + enableSorting: true, + enableHiding: false, + meta: { + excelHeader: 'Bulk-Marine Engineering Score', + group: 'Bulk Score', + type: 'number', + }, + }, + ]; + + // [4] REMARKS COLUMN + const remarksColumn: ColumnDef<RegEvalCriteriaView> = { + accessorKey: 'remarks', + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="비고" /> + ), + cell: ({ row }) => ( + <div className="font-regular"> + {row.getValue('remarks') || '-'} + </div> + ), + enableSorting: true, + enableHiding: false, + meta: { + excelHeader: 'Remarks', + type: 'text', + }, + }; + + // [5] ACTIONS COLUMN - DROPDOWN MENU + const actionsColumn: ColumnDef<RegEvalCriteriaView> = { + id: 'actions', + header: '작업', + enableHiding: false, + cell: function Cell({ row }) { + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="ghost" size="icon"> + <PenToolIcon className="h-4 w-4" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuItem + onClick={() => setRowAction({ row, type: 'update' })} + > + <PenToolIcon className="mr-2 h-4 w-4" /> + Modify Criteria + </DropdownMenuItem> + <DropdownMenuItem + onClick={() => setRowAction({ row, type: 'delete' })} + className="text-destructive" + > + <TrashIcon className="mr-2 h-4 w-4" /> + Delete Criteria + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ) + }, + size: 80, + }; + + return [ + selectColumn, + ...criteriaColumns, + { + id: 'score', + header: '배점', + columns: [ + { + id: 'scoreEquip', + header: '기자재', + columns: scoreEquipColumns, + }, + { + id: 'scoreBulk', + header: '벌크', + columns: scoreBulkColumns, + }, + ], + }, + remarksColumn, + actionsColumn, + ]; +}; + +// ---------------------------------------------------------------------------------------------------- + +/* EXPORT */ +export default getColumns;
\ No newline at end of file diff --git a/lib/evaluation-criteria/table/reg-eval-criteria-delete-dialog.tsx b/lib/evaluation-criteria/table/reg-eval-criteria-delete-dialog.tsx new file mode 100644 index 00000000..aac7db29 --- /dev/null +++ b/lib/evaluation-criteria/table/reg-eval-criteria-delete-dialog.tsx @@ -0,0 +1,147 @@ +'use client'; + +/* IMPORT */ +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + getRegEvalCriteriaWithDetails, + removeRegEvalCriteria, +} from '../service'; +import { LoaderCircle } from 'lucide-react'; +import { toast } from 'sonner'; +import { + REG_EVAL_CRITERIA_CATEGORY, + REG_EVAL_CRITERIA_ITEM, + REG_EVAL_CRITERIA_SCORE_CATEGORY, + type RegEvalCriteriaView, + type RegEvalCriteriaWithDetails, +} from '@/db/schema'; +import { useEffect, useState } from 'react'; + +// ---------------------------------------------------------------------------------------------------- + +/* TYPES */ +interface RegEvalCriteriaDeleteDialogProps { + open: boolean, + onOpenChange: (open: boolean) => void, + criteriaViewData: RegEvalCriteriaView, + onSuccess: () => void, +} + +// ---------------------------------------------------------------------------------------------------- + +/* REGULAR EVALUATION CRITERIA DELETE DIALOG COMPONENT */ +function RegEvalCriteriaDeleteDialog(props: RegEvalCriteriaDeleteDialogProps) { + const { open, onOpenChange, criteriaViewData, onSuccess } = props; + const [isLoading, setIsLoading] = useState<boolean>(false); + const [isDeleting, setIsDeleting] = useState<boolean>(false); + const [targetData, setTargetData] = useState<RegEvalCriteriaWithDetails | null>(); + + useEffect(() => { + const fetchData = async () => { + if (!criteriaViewData?.criteriaId) { + return; + } + setIsLoading(true); + try { + const result = await getRegEvalCriteriaWithDetails(criteriaViewData.criteriaId); + setTargetData(result); + } catch (error) { + console.error('Error in Loading Target Data for Deletion: ', error); + } finally { + setIsLoading(false); + } + } + fetchData(); + }, [criteriaViewData.criteriaId]); + + const handleDelete = async () => { + if (!criteriaViewData || !criteriaViewData.criteriaId) { + return; + } + + try { + setIsDeleting(true); + await removeRegEvalCriteria(criteriaViewData.criteriaId); + toast.success('평가 기준이 삭제되었습니다.'); + onSuccess(); + } catch (error) { + console.error('Error in Deleting Regular Evaluation Criteria: ', error); + toast.error( + error instanceof Error ? error.message : '삭제 중 오류가 발생했습니다.' + ); + } finally { + setIsDeleting(false); + } + } + + if (!criteriaViewData) { + return null; + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent> + {isLoading ? ( + <div className="flex flex-col items-center justify-center h-32"> + <LoaderCircle className="h-8 w-8 animate-spin" /> + <p className="mt-4 text-base text-gray-700">Loading...</p> + </div> + ) : ( + <> + <DialogHeader> + <DialogTitle>협력업체 평가 기준 삭제</DialogTitle> + <DialogDescription> + 정말로 이 협력업체 평가 기준을 삭제하시겠습니까? + <br /> + <br /> + <strong>삭제될 평가 기준:</strong> + <br /> + • 평가부문: {REG_EVAL_CRITERIA_CATEGORY.find((c) => c.value === criteriaViewData.category)?.label ?? '-'} + <br /> + • 점수구분: {REG_EVAL_CRITERIA_SCORE_CATEGORY.find((c) => c.value === criteriaViewData.scoreCategory)?.label ?? '-'} + <br /> + • 항목: {REG_EVAL_CRITERIA_ITEM.find((c) => c.value === criteriaViewData.item)?.label ?? '-'} + <br /> + • 구분: {criteriaViewData.classification || '-'} + <br /> + • 범위: {criteriaViewData.range || '-'} + <br /> + <br /> + <b>이 작업은 되돌릴 수 없으며</b>, 평가기준과 그에 속한 <b>{targetData?.criteriaDetails.length}개의 평가항목도 함께 삭제</b>됩니다. + </DialogDescription> + </DialogHeader> + <DialogFooter> + <Button + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isDeleting} + > + Cancel + </Button> + <Button + variant="destructive" + onClick={handleDelete} + disabled={isDeleting} + > + {isDeleting ? 'Deleting...' : 'Delete'} + </Button> + </DialogFooter> + </> + )} + </DialogContent> + </Dialog> + ) +} + +// ---------------------------------------------------------------------------------------------------- + +/* EXPORT */ +export default RegEvalCriteriaDeleteDialog;
\ No newline at end of file diff --git a/lib/evaluation-criteria/table/reg-eval-criteria-form-sheet.tsx b/lib/evaluation-criteria/table/reg-eval-criteria-form-sheet.tsx new file mode 100644 index 00000000..d33e7d29 --- /dev/null +++ b/lib/evaluation-criteria/table/reg-eval-criteria-form-sheet.tsx @@ -0,0 +1,586 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +'use client'; + +/* IMPORT */ +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { + createRegEvalCriteriaWithDetails, + getRegEvalCriteriaWithDetails, + modifyRegEvalCriteriaWithDetails, +} from '../service'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { Plus, Trash2 } from 'lucide-react'; +import { + REG_EVAL_CRITERIA_CATEGORY, + REG_EVAL_CRITERIA_CATEGORY2, + REG_EVAL_CRITERIA_ITEM, + type RegEvalCriteriaDetails, + type RegEvalCriteriaView, +} from '@/db/schema'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select'; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from '@/components/ui/sheet'; +import { Textarea } from '@/components/ui/textarea'; +import { toast } from 'sonner'; +import { useForm, useFieldArray } from 'react-hook-form'; +import { useEffect, useTransition } from 'react'; +import { z } from 'zod'; +import { zodResolver } from '@hookform/resolvers/zod'; + +// ---------------------------------------------------------------------------------------------------- + +/* TYPES */ +const regEvalCriteriaFormSchema = z.object({ + category: z.string().min(1, '평가부문은 필수 항목입니다.'), + scoreCategory: z.string().min(1, '점수구분은 필수 항목입니다.'), + item: z.string().min(1, '항목은 필수 항목입니다.'), + classification: z.string().min(1, '구분은 필수 항목입니다.'), + range: z.string().nullable().optional(), + remarks: z.string().nullable().optional(), + criteriaDetails: z.array( + z.object({ + id: z.number().optional(), + detail: z.string().min(1, '평가내용은 필수 항목입니다.'), + scoreEquipShip: z.coerce.number().nullable().optional(), + scoreEquipMarine: z.coerce.number().nullable().optional(), + scoreBulkShip: z.coerce.number().nullable().optional(), + scoreBulkMarine: z.coerce.number().nullable().optional(), + }) + ).min(1, '최소 1개의 평가 내용이 필요합니다.'), +}); +type RegEvalCriteriaFormData = z.infer<typeof regEvalCriteriaFormSchema>; +interface CriteriaDetailFormProps { + index: number + form: any + onRemove: () => void + canRemove: boolean + disabled?: boolean +} +interface RegEvalCriteriaFormSheetProps { + open: boolean, + onOpenChange: (open: boolean) => void, + criteriaViewData: RegEvalCriteriaView | null, + onSuccess: () => void, +}; + +// ---------------------------------------------------------------------------------------------------- + +/* CRITERIA DETAIL FORM COPONENT */ +function CriteriaDetailForm({ + index, + form, + onRemove, + canRemove, + disabled = false, +}: CriteriaDetailFormProps) { + + return ( + <Card> + <CardHeader> + <div className="flex items-center justify-between"> + <CardTitle className="text-lg">Detail Item - {index + 1}</CardTitle> + {canRemove && ( + <Button + type="button" + variant="ghost" + size="sm" + onClick={onRemove} + className="text-destructive hover:text-destructive" + disabled={disabled} + > + <Trash2 className="w-4 h-4" /> + </Button> + )} + </div> + </CardHeader> + <CardContent className="space-y-4"> + <FormField + control={form.control} + name={`criteriaDetails.${index}.id`} + render={({ field }) => ( + <Input type="hidden" {...field} /> + )} + /> + <FormField + control={form.control} + name={`criteriaDetails.${index}.detail`} + render={({ field }) => ( + <FormItem> + <FormLabel>평가내용</FormLabel> + <FormControl> + <Textarea + placeholder="평가내용을 입력하세요." + {...field} + disabled={disabled} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name={`criteriaDetails.${index}.scoreEquipShip`} + render={({ field }) => ( + <FormItem> + <FormLabel>배점/기자재/조선</FormLabel> + <FormControl> + <Input + type="number" + step="0.1" + placeholder="배점/기자재/조선" + {...field} + value={field.value ?? 0} + disabled={disabled} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name={`criteriaDetails.${index}.scoreEquipMarine`} + render={({ field }) => ( + <FormItem> + <FormLabel>배점/기자재/해양</FormLabel> + <FormControl> + <Input + type="number" + step="0.1" + placeholder="배점/기자재/해양" + {...field} + value={field.value ?? 0} + disabled={disabled} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name={`criteriaDetails.${index}.scoreBulkShip`} + render={({ field }) => ( + <FormItem> + <FormLabel>배점/벌크/조선</FormLabel> + <FormControl> + <Input + type="number" + step="0.1" + placeholder="배점/벌크/조선" + {...field} + value={field.value ?? 0} + disabled={disabled} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name={`criteriaDetails.${index}.scoreBulkMarine`} + render={({ field }) => ( + <FormItem> + <FormLabel>배점/벌크/해양</FormLabel> + <FormControl> + <Input + type="number" + step="0.1" + placeholder="배점/벌크/해양" + {...field} + value={field.value ?? 0} + disabled={disabled} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </CardContent> + </Card> + ) +} + +/* CRITERIA FORM SHEET COPONENT */ +function RegEvalCriteriaFormSheet({ + open, + onOpenChange, + criteriaViewData, + onSuccess, +}: RegEvalCriteriaFormSheetProps) { + const [isPending, startTransition] = useTransition(); + const isUpdateMode = !!criteriaViewData; + + const form = useForm<RegEvalCriteriaFormData>({ + resolver: zodResolver(regEvalCriteriaFormSchema), + defaultValues: { + category: '', + scoreCategory: '', + item: '', + classification: '', + range: '', + remarks: '', + criteriaDetails: [ + { + id: undefined, + detail: '', + scoreEquipShip: null, + scoreEquipMarine: null, + scoreBulkShip: null, + scoreBulkMarine: null, + }, + ], + }, + }); + + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: 'criteriaDetails', + }); + + useEffect(() => { + if (open && isUpdateMode && criteriaViewData) { + startTransition(async () => { + try { + const targetData = await getRegEvalCriteriaWithDetails(criteriaViewData.criteriaId!); + if (targetData) { + form.reset({ + category: targetData.category, + scoreCategory: targetData.scoreCategory, + item: targetData.item, + classification: targetData.classification, + range: targetData.range, + remarks: targetData.remarks, + criteriaDetails: targetData.criteriaDetails?.map((detailItem: RegEvalCriteriaDetails) => ({ + id: detailItem.id, + detail: detailItem.detail, + scoreEquipShip: detailItem.scoreEquipShip !== null + ? Number(detailItem.scoreEquipShip) : null, + scoreEquipMarine: detailItem.scoreEquipMarine !== null + ? Number(detailItem.scoreEquipMarine) : null, + scoreBulkShip: detailItem.scoreBulkShip !== null + ? Number(detailItem.scoreBulkShip) : null, + scoreBulkMarine: detailItem.scoreBulkMarine !== null + ? Number(detailItem.scoreBulkMarine) : null, + })) || [], + }) + } + } catch (error) { + console.error('Error in Loading Regular Evaluation Criteria for Updating:', error) + toast.error(error instanceof Error ? error.message : '편집할 데이터를 불러오는 데 실패했습니다.') + } + }) + } else if (open && !isUpdateMode) { + form.reset({ + category: '', + scoreCategory: '', + item: '', + classification: '', + range: '', + remarks: '', + criteriaDetails: [ + { + id: undefined, + detail: '', + scoreEquipShip: null, + scoreEquipMarine: null, + scoreBulkShip: null, + scoreBulkMarine: null, + }, + ], + }) + } + }, [open, isUpdateMode, criteriaViewData, form]); + + const onSubmit = async (data: RegEvalCriteriaFormData) => { + startTransition(async () => { + try { + const criteriaData = { + category: data.category, + scoreCategory: data.scoreCategory, + item: data.item, + classification: data.classification, + range: data.range, + remarks: data.remarks, + }; + const detailList = data.criteriaDetails.map((detailItem) => ({ + id: detailItem.id, + detail: detailItem.detail, + scoreEquipShip: detailItem.scoreEquipShip != null + ? String(detailItem.scoreEquipShip) : null, + scoreEquipMarine: detailItem.scoreEquipMarine != null + ? String(detailItem.scoreEquipMarine) : null, + scoreBulkShip: detailItem.scoreBulkShip != null + ? String(detailItem.scoreBulkShip) : null, + scoreBulkMarine: detailItem.scoreBulkMarine != null + ? String(detailItem.scoreBulkMarine) : null, + })); + + if (isUpdateMode && criteriaViewData) { + await modifyRegEvalCriteriaWithDetails(criteriaViewData.criteriaId!, criteriaData, detailList); + toast.success('평가 기준표가 수정되었습니다.'); + } else { + await createRegEvalCriteriaWithDetails(criteriaData, detailList); + toast.success('평가 기준표가 생성되었습니다.'); + } + onSuccess(); + onOpenChange(false); + } catch (error) { + console.error('Error in Saving Regular Evaluation Criteria:', error); + toast.error( + error instanceof Error ? error.message : '평가 기준표 저장 중 오류가 발생했습니다.' + ); + } + }) + } + + if (!open) { + return null; + } + + return ( + <Sheet open={open} onOpenChange={onOpenChange}> + <SheetContent className="w-[900px] sm:max-w-[900px] overflow-y-auto"> + <SheetHeader className="mb-4"> + <SheetTitle className="font-bold"> + {isUpdateMode ? '협력업체 평가 기준표 수정' : '새 협력업체 평가 기준표 생성'} + </SheetTitle> + <SheetDescription> + {isUpdateMode ? '협력업체 평가 기준표의 정보를 수정합니다.' : '새로운 협력업체 평가 기준표를 생성합니다.'} + </SheetDescription> + </SheetHeader> + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> + <ScrollArea className="h-[calc(100vh-200px)] pr-4"> + <div className="space-y-6"> + <Card> + <CardHeader> + <CardTitle>Criterion Info</CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + <FormField + control={form.control} + name="category" + render={({ field }) => ( + <FormItem> + <FormLabel>평가부문</FormLabel> + <FormControl> + <Select onValueChange={field.onChange} value={field.value || ""}> + <SelectTrigger> + <SelectValue placeholder="선택" /> + </SelectTrigger> + <SelectContent> + {REG_EVAL_CRITERIA_CATEGORY.map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="scoreCategory" + render={({ field }) => ( + <FormItem> + <FormLabel>점수구분</FormLabel> + <FormControl> + <Select onValueChange={field.onChange} value={field.value || ""}> + <SelectTrigger> + <SelectValue placeholder="선택" /> + </SelectTrigger> + <SelectContent> + {REG_EVAL_CRITERIA_CATEGORY2.map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="item" + render={({ field }) => ( + <FormItem> + <FormLabel>항목</FormLabel> + <FormControl> + <Select onValueChange={field.onChange} value={field.value || ""}> + <SelectTrigger> + <SelectValue placeholder="선택" /> + </SelectTrigger> + <SelectContent> + {REG_EVAL_CRITERIA_ITEM.map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="classification" + render={({ field }) => ( + <FormItem> + <FormLabel>구분</FormLabel> + <FormControl> + <Input placeholder="구분을 입력하세요." {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="range" + render={({ field }) => ( + <FormItem> + <FormLabel>범위</FormLabel> + <FormControl> + <Input + placeholder="범위를 입력하세요." {...field} + value={field.value ?? ''} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="remarks" + render={({ field }) => ( + <FormItem> + <FormLabel>비고</FormLabel> + <FormControl> + <Textarea + placeholder="비고를 입력하세요." + {...field} + value={field.value ?? ''} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </CardContent> + </Card> + <Card> + <CardHeader> + <div className="flex items-center justify-between"> + <div> + <CardTitle>Evaluation Criteria Item</CardTitle> + <CardDescription> + Set Evaluation Criteria Item. + </CardDescription> + </div> + <Button + type="button" + variant="outline" + size="sm" + className="ml-4" + onClick={() => + append({ + id: undefined, + detail: '', + scoreEquipShip: null, + scoreEquipMarine: null, + scoreBulkShip: null, + scoreBulkMarine: null, + }) + } + disabled={isPending} + > + <Plus className="w-4 h-4 mr-2" /> + New Item + </Button> + </div> + </CardHeader> + <CardContent> + <div className="space-y-4"> + {fields.map((field, index) => ( + <CriteriaDetailForm + key={field.id} + index={index} + form={form} + onRemove={() => remove(index)} + canRemove={fields.length > 1} + disabled={isPending} + /> + ))} + </div> + </CardContent> + </Card> + </div> + </ScrollArea> + <div className="flex justify-end gap-2 pt-4 border-t"> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isPending} + > + Cancel + </Button> + <Button type="submit" disabled={isPending}> + {isPending + ? 'Saving...' + : isUpdateMode + ? 'Modify' + : 'Create'} + </Button> + </div> + </form> + </Form> + </SheetContent> + </Sheet> + ) +} + +// ---------------------------------------------------------------------------------------------------- + +/* EXPORT */ +export default RegEvalCriteriaFormSheet;
\ No newline at end of file diff --git a/lib/evaluation-criteria/table/reg-eval-criteria-table-toolbar-actions.tsx b/lib/evaluation-criteria/table/reg-eval-criteria-table-toolbar-actions.tsx new file mode 100644 index 00000000..95b2171e --- /dev/null +++ b/lib/evaluation-criteria/table/reg-eval-criteria-table-toolbar-actions.tsx @@ -0,0 +1,161 @@ +'use client'; + +/* IMPORT */ +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger +} from '@/components/ui/alert-dialog'; +import { Button } from '@/components/ui/button'; +import { Download, Plus, Trash2 } from 'lucide-react'; +import { exportTableToExcel } from '@/lib/export'; +import { removeRegEvalCriteria } from '../service'; +import { type RegEvalCriteriaView } from '@/db/schema'; +import { type Table } from '@tanstack/react-table'; +import { toast } from 'sonner'; +import { useMemo, useState } from 'react'; + +// ---------------------------------------------------------------------------------------------------- + +/* TYPES */ +interface RegEvalCriteriaTableToolbarActionsProps { + table: Table<RegEvalCriteriaView>, + onCreateCriteria?: () => void, + onRefresh?: () => void, +} + +// ---------------------------------------------------------------------------------------------------- + +/* REGULAR EVALUATION CRITERIA TABLE TOOLBAR ACTIONS COMPONENT */ +function RegEvalCriteriaTableToolbarActions(props: RegEvalCriteriaTableToolbarActionsProps) { + const { table, onCreateCriteria, onRefresh } = props; + const [isDeleting, setIsDeleting] = useState<boolean>(false); + const selectedRows = table.getFilteredSelectedRowModel().rows; + const hasSelection = selectedRows.length > 0; + const selectedIds = useMemo(() => { + return [...new Set(selectedRows.map(row => row.original.criteriaId))]; + }, [selectedRows]); + + // Function for Create New Criteria + const handleCreateNew = () => { + if (!onCreateCriteria) { + return; + } + onCreateCriteria(); + } + + const handleDeleteSelected = async () => { + if (!hasSelection) { + return; + } + try { + setIsDeleting(true); + + for (const selectedId of selectedIds) { + if (selectedId) { + await removeRegEvalCriteria(selectedId); + } + } + table.resetRowSelection(); + toast.success(`${selectedIds.length}개의 평가 기준이 삭제되었습니다.`); + + if (onRefresh) { + onRefresh(); + } else { + window.location.reload(); + } + } catch (error) { + console.error('Error in Deleting Regular Evaluation Critria: ', error); + toast.error( + error instanceof Error + ? error.message + : '평가 기준 삭제 중 오류가 발생했습니다.' + ); + } finally { + setIsDeleting(false); + } + } + + // Excel Export + const handleExport = () => { + try { + exportTableToExcel(table, { + filename: 'Regular_Evaluation_Criteria', + excludeColumns: ['select', 'actions'], + }); + toast.success('Excel 파일이 다운로드되었습니다.'); + } catch (error) { + console.error('Error in Exporting to Excel: ', error); + toast.error('Excel 내보내기 중 오류가 발생했습니다.'); + } + }; + + return ( + <div className="flex items-center gap-2"> + <Button + variant="default" + size="sm" + className="gap-2" + onClick={handleCreateNew} + > + <Plus className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">New Criteria</span> + </Button> + {hasSelection && ( + <AlertDialog> + <AlertDialogTrigger asChild> + <Button + variant="destructive" + size="sm" + className="gap-2" + disabled={isDeleting} + > + <Trash2 className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline"> + 선택 삭제 ({selectedIds.length}) + </span> + </Button> + </AlertDialogTrigger> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>정말 삭제하시겠습니까?</AlertDialogTitle> + <AlertDialogDescription> + 선택된 {selectedIds.length}개의 협력업체 평가 기준 항목이 영구적으로 삭제됩니다. + 이 작업은 되돌릴 수 없으며, 연관된 평가 기준과 항목들도 함께 삭제됩니다. + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel>Cancel</AlertDialogCancel> + <AlertDialogAction + onClick={handleDeleteSelected} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {isDeleting ? 'Deleting...' : 'Delete'} + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + )} + <Button + variant="outline" + size="sm" + onClick={handleExport} + className="gap-2" + > + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Export</span> + </Button> + </div> + ); +} + +// ---------------------------------------------------------------------------------------------------- + +/* EXPORT */ +export default RegEvalCriteriaTableToolbarActions;
\ No newline at end of file diff --git a/lib/evaluation-criteria/table/reg-eval-criteria-table.tsx b/lib/evaluation-criteria/table/reg-eval-criteria-table.tsx new file mode 100644 index 00000000..a2242309 --- /dev/null +++ b/lib/evaluation-criteria/table/reg-eval-criteria-table.tsx @@ -0,0 +1,189 @@ +'use client'; + +/* IMPORT */ +import { DataTable } from '@/components/data-table/data-table'; +import { DataTableAdvancedToolbar } from '@/components/data-table/data-table-advanced-toolbar'; +import getColumns from './reg-eval-criteria-columns'; +import { getRegEvalCriteria } from '../service'; +import { + REG_EVAL_CRITERIA_CATEGORY, + REG_EVAL_CRITERIA_ITEM, + REG_EVAL_CRITERIA_CATEGORY2, + type RegEvalCriteriaView +} from '@/db/schema'; +import RegEvalCriteriaDeleteDialog from './reg-eval-criteria-delete-dialog'; +import RegEvalCriteriaFormSheet from './reg-eval-criteria-form-sheet'; +import RegEvalCriteriaTableToolbarActions from './reg-eval-criteria-table-toolbar-actions'; +import { + type DataTableFilterField, + type DataTableRowAction, + type DataTableAdvancedFilterField, +} from '@/types/table'; +import { useDataTable } from '@/hooks/use-data-table'; +import { use, useCallback, useMemo, useState } from 'react'; +import { useRouter } from 'next/navigation'; + +// ---------------------------------------------------------------------------------------------------- + +/* TYPES */ +interface RegEvalCriteriaTableProps { + promises: Promise<[ + Awaited<ReturnType<typeof getRegEvalCriteria>>, + ]>, +} + +// ---------------------------------------------------------------------------------------------------- + +/* TABLE COMPONENT */ +function RegEvalCriteriaTable({ promises }: RegEvalCriteriaTableProps) { + const router = useRouter(); + const [rowAction, setRowAction] = useState<DataTableRowAction<RegEvalCriteriaView> | null>(null); + const [isCreateFormOpen, setIsCreateFormOpen] = useState<boolean>(false); + const [promiseData] = use(promises); + const tableData = promiseData; + + const columns = useMemo( + () => getColumns({ setRowAction }), + [setRowAction], + ); + + const filterFields: DataTableFilterField<RegEvalCriteriaView>[] = [ + { + id: 'category', + label: '평가부문', + placeholder: '평가부문 선택...', + }, + { + id: 'scoreCategory', + label: '점수구분', + placeholder: '점수구분 선택...', + }, + { + id: 'item', + label: '항목', + placeholder: '항목 선택...', + }, + ] + const advancedFilterFields: DataTableAdvancedFilterField<RegEvalCriteriaView>[] = [ + { + id: 'category', + label: '평가부문', + type: 'select', + options: REG_EVAL_CRITERIA_CATEGORY, + }, + { + id: 'scoreCategory', + label: '점수구분', + type: 'select', + options: REG_EVAL_CRITERIA_CATEGORY2, + }, + { + id: 'item', + label: '항목', + type: 'select', + options: REG_EVAL_CRITERIA_ITEM, + }, + { id: 'classification', label: '구분', type: 'text' }, + { id: 'range', label: '범위', type: 'text' }, + { id: 'detail', label: '평가내용', type: 'text' }, + { id: 'scoreEquipShip', label: '조선', type: 'number' }, + { id: 'scoreEquipMarine', label: '해양', type: 'number' }, + { id: 'scoreBulkShip', label: '조선', type: 'number' }, + { id: 'scoreBulkMarine', label: '해양', type: 'number' }, + { id: 'remarks', label: '비고', type: 'text' }, + ]; + + // Data Table Setting + const { table } = useDataTable({ + data: tableData.data, + columns, + pageCount: tableData.pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: 'id', desc: false }], + columnPinning: { left: ['select'], right: ['actions'] }, + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + }); + + const emptyCriteriaViewData: RegEvalCriteriaView = { + id: 0, + category: '', + scoreCategory: '', + item: '', + classification: '', + range: null, + remarks: null, + criteriaId: 0, + detail: '', + orderIndex: null, + scoreEquipShip: null, + scoreEquipMarine: null, + scoreBulkShip: null, + scoreBulkMarine: null, + }; + + const refreshData = useCallback(() => { + router.refresh(); + }, [router]); + const handleCreateCriteria = () => { + setIsCreateFormOpen(true); + }; + const handleCreateSuccess = useCallback(() => { + setIsCreateFormOpen(false); + refreshData(); + }, [refreshData]); + const handleModifySuccess = useCallback(() => { + setRowAction(null); + refreshData(); + }, [refreshData]); + const handleDeleteSuccess = useCallback(() => { + setRowAction(null); + refreshData(); + }, [refreshData]); + + return ( + <> + <DataTable table={table}> + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <RegEvalCriteriaTableToolbarActions + table={table} + onCreateCriteria={handleCreateCriteria} + onRefresh={refreshData} + /> + </DataTableAdvancedToolbar> + </DataTable> + <RegEvalCriteriaFormSheet + open={isCreateFormOpen} + onOpenChange={setIsCreateFormOpen} + criteriaViewData={null} + onSuccess={handleCreateSuccess} + /> + <RegEvalCriteriaFormSheet + open={rowAction?.type === 'update'} + onOpenChange={() => setRowAction(null)} + criteriaViewData={rowAction?.row.original ?? null} + onSuccess={handleModifySuccess} + /> + <RegEvalCriteriaDeleteDialog + open={rowAction?.type === 'delete'} + onOpenChange={() => setRowAction(null)} + criteriaViewData={rowAction?.row.original ?? emptyCriteriaViewData} + onSuccess={handleDeleteSuccess} + /> + </> + ) +} + +// ---------------------------------------------------------------------------------------------------- + +/* EXPORT */ +export default RegEvalCriteriaTable; |
