diff options
| author | joonhoekim <26rote@gmail.com> | 2025-08-14 00:26:53 +0000 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-08-14 00:26:53 +0000 |
| commit | dd20ba9785cdbd3d61f6b014d003d3bd9646ad13 (patch) | |
| tree | 4e99d62311a6c115dbc894084714a29c34bca11a /lib/risk-management/table | |
| parent | 33be47506f0aa62b969d82521580a29e95080268 (diff) | |
(고건) 리스크 관리 페이지 추가
Diffstat (limited to 'lib/risk-management/table')
| -rw-r--r-- | lib/risk-management/table/risks-columns.tsx | 352 | ||||
| -rw-r--r-- | lib/risk-management/table/risks-dashboard.tsx | 244 | ||||
| -rw-r--r-- | lib/risk-management/table/risks-date-range-picker.tsx | 157 | ||||
| -rw-r--r-- | lib/risk-management/table/risks-mail-dialog.tsx | 560 | ||||
| -rw-r--r-- | lib/risk-management/table/risks-table-toolbar-actions.tsx | 161 | ||||
| -rw-r--r-- | lib/risk-management/table/risks-table.tsx | 176 | ||||
| -rw-r--r-- | lib/risk-management/table/risks-update-sheet.tsx | 406 | ||||
| -rw-r--r-- | lib/risk-management/table/user-combo-box.tsx | 127 |
8 files changed, 2183 insertions, 0 deletions
diff --git a/lib/risk-management/table/risks-columns.tsx b/lib/risk-management/table/risks-columns.tsx new file mode 100644 index 00000000..fe98448a --- /dev/null +++ b/lib/risk-management/table/risks-columns.tsx @@ -0,0 +1,352 @@ +'use client'; + +/* IMPORT */ +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { CircleCheckBig, CircleX, Ellipsis, Handshake, OctagonAlert } from 'lucide-react'; +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 { type ColumnDef } from '@tanstack/react-table'; +import { type DataTableRowAction } from '@/types/table'; +import { type RisksView } from '@/db/schema'; + +// ---------------------------------------------------------------------------------------------------- + +/* TYPES */ +interface GetColumnsProps { + setRowAction: Dispatch<SetStateAction<DataTableRowAction<RisksView> | null>>, +}; + +// ---------------------------------------------------------------------------------------------------- + +/* FUNCTION FOR GETTING COLUMNS SETTING */ +function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<RisksView>[] { + + // [1] SELECT COLUMN - CHECKBOX + const selectColumn: ColumnDef<RisksView> = { + 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] SOURCE COLUMNS + const sourceColumns: ColumnDef<RisksView>[] = [ + { + accessorKey: 'eventType', + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="항목" /> + ), + cell: ({ row }) => { + const value = row.getValue<string>('eventType'); + return ( + <Badge variant="default"> + {value} + </Badge> + ); + }, + enableSorting: true, + enableHiding: false, + meta: { + excelHeader: 'Risk Category', + group: 'Risk Information', + type: 'select', + }, + }, + { + accessorKey: 'vendorCode', + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="협력업체 코드" /> + ), + cell: ({ row }) => { + const value = row.getValue<string>('vendorCode'); + return ( + <div className="font-regular"> + {value} + </div> + ); + }, + enableSorting: true, + enableHiding: false, + meta: { + excelHeader: 'Vendor Code', + group: 'Risk Information', + type: 'text', + }, + }, + { + accessorKey: 'vendorName', + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="협력업체명" /> + ), + cell: ({ row }) => { + const value = row.getValue<string>('vendorName'); + return ( + <div className="font-regular"> + {value} + </div> + ); + }, + enableSorting: true, + enableHiding: false, + meta: { + excelHeader: 'Vendor Name', + group: 'Risk Information', + type: 'text', + }, + }, + { + accessorKey: 'businessNumber', + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="사업자등록번호" /> + ), + cell: ({ row }) => { + const value = row.getValue<string>('businessNumber'); + const digits = value.replace(/\D/g, ''); + const formattedValue = digits.length === 10 + ? `${digits.slice(0, 3)}-${digits.slice(3, 5)}-${digits.slice(5)}` + : value; + return ( + <div className="font-regular"> + {formattedValue} + </div> + ); + }, + enableSorting: true, + enableHiding: false, + meta: { + excelHeader: 'Business Number', + group: 'Risk Information', + type: 'text', + }, + }, + { + accessorKey: 'provider', + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="신용평가사" /> + ), + cell: ({ row }) => { + const value = row.getValue<string>('provider'); + return ( + <Badge variant="secondary"> + {value} + </Badge> + ); + }, + enableSorting: true, + enableHiding: false, + meta: { + excelHeader: 'Provider', + group: 'Risk Information', + type: 'text', + }, + }, + { + accessorKey: 'content', + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="상세 내용" /> + ), + cell: ({ row }) => { + const value = row.getValue<string>('content') ?? '-'; + return ( + <div className="font-regular max-w-[150px] truncate" title={value}> + {value} + </div> + ); + }, + enableSorting: true, + enableHiding: false, + meta: { + excelHeader: 'Risk Content', + group: 'Risk Information', + type: 'text', + }, + size: 100, + }, + { + accessorKey: 'occuredAt', + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="발생일시" /> + ), + cell: ({ row }) => { + const date = row.getValue<Date>('occuredAt'); + return ( + <div className="font-regular"> + {date ? new Date(date).toLocaleDateString('ko-KR') : '-'} + </div> + ); + }, + enableSorting: true, + enableHiding: false, + meta: { + excelHeader: 'Occured At', + group: 'Risk Information', + type: 'date', + }, + }, + ]; + + // [3] INPUT COLUMNS + const inputColumns: ColumnDef<RisksView>[] = [ + { + accessorKey: 'eventStatus', + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="리스크 해소 여부" /> + ), + cell: ({ row }) => { + const value = row.getValue<boolean>('eventStatus'); + + if (value) { + return ( + <div className="flex items-center gap-2 text-destructive font-bold"> + <CircleX size={20} strokeWidth={2} /> + 아니오 + </div> + ); + } + return ( + <div className="flex items-center gap-2 text-primary font-bold"> + <CircleCheckBig size={20} strokeWidth={2} /> + 예 + </div> + ); + }, + enableSorting: true, + enableHiding: false, + meta: { + excelHeader: 'Risk Not Cleared', + group: 'Risk Management', + type: 'text', + }, + }, + { + accessorKey: 'managerName', + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="구매 담당자" /> + ), + cell: ({ row }) => { + const value = row.getValue<string>('managerName') ?? '-'; + return ( + <div className="font-regular"> + {value} + </div> + ); + }, + enableSorting: true, + enableHiding: false, + meta: { + excelHeader: 'Procurement Manager Name', + group: 'Risk Management', + type: 'text', + }, + }, + { + accessorKey: 'adminComment', + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="관리 담당자 의견" /> + ), + cell: ({ row }) => { + const value = row.getValue<string>('adminComment') ?? '-'; + return ( + <div className="font-regular max-w-[150px] truncate" title={value}> + {value} + </div> + ); + }, + enableSorting: true, + enableHiding: false, + meta: { + excelHeader: 'Risk Manager Comment', + group: 'Risk Management', + type: 'text', + }, + size: 300, + }, + ]; + + // [4] ACTIONS COLUMN - DROPDOWN MENU WITH VIEW ACTION + const actionsColumn: ColumnDef<RisksView> = { + id: 'actions', + enableHiding: false, + cell: function Cell({ row }) { + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-8 p-0 data-[state=open]:bg-muted" + > + <Ellipsis className="size-4" aria-hidden="true" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-60"> + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "update" })} + className="cursor-pointer" + > + <OctagonAlert className="mr-2 size-4" /> + 리스크 관리 + </DropdownMenuItem> + <DropdownMenuItem + onSelect={() => { + // 신용정보 관리화면으로 이동 + }} + className="cursor-pointer" + > + <Handshake className="mr-2 size-4" /> + 신용정보 확인 + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ) + }, + size: 80, + }; + + return [ + selectColumn, + { + id: 'riskSource', + header: '리스크 정보', + columns: sourceColumns, + }, + { + id: 'riskInput', + header: '리스크 관리', + columns: inputColumns, + }, + actionsColumn, + ]; +}; + +// ---------------------------------------------------------------------------------------------------- + +/* EXPORT */ +export default getColumns;
\ No newline at end of file diff --git a/lib/risk-management/table/risks-dashboard.tsx b/lib/risk-management/table/risks-dashboard.tsx new file mode 100644 index 00000000..1f26d48a --- /dev/null +++ b/lib/risk-management/table/risks-dashboard.tsx @@ -0,0 +1,244 @@ +'use client'; + +/* IMPORT */ +import { Bar, BarChart, Cell, LabelList, XAxis, YAxis } from 'recharts'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'; +import { getRisksViewCount } from '../service'; +import { LoaderCircle } from 'lucide-react'; +import { type DateRange } from 'react-day-picker'; +import { useEffect, useState, useMemo, useCallback } from 'react'; +import { useParams, useRouter, useSearchParams } from 'next/navigation'; +import { ValueType } from 'recharts/types/component/DefaultTooltipContent'; + +// ---------------------------------------------------------------------------------------------------- + +/* TYPES */ +interface RisksDashboardProps { + targetValues: string[]; + defaultDateRange: DateRange; +} + +interface CountData { + [key: string]: number; +} + +interface ChartData { + name: string; + count: number; + color: string; +} + +// ---------------------------------------------------------------------------------------------------- + +/* RISKS DASHBOARD COMPONENT */ +function RisksDashboard(props: RisksDashboardProps) { + const { targetValues, defaultDateRange } = props; + const router = useRouter(); + const searchParams = useSearchParams(); + const params = useParams(); + const [counts, setCounts] = useState<CountData>({}); + const [isLoading, setIsLoading] = useState(true); + const [dateQuery, setDateQuery] = useState({ + from: defaultDateRange.from?.toISOString() ?? '', + to: defaultDateRange.to?.toISOString() ?? '', + search: '', + }); + const language = params?.lng as string; + + const chartData: ChartData[] = useMemo(() => { + const chartItems = ['단기연체', '노무비체불', '세금체납', '채무불이행', '행정처분', '당좌거래정지', '기업회생', '휴/폐업']; + const colors = [ + '#22c55e', + '#6dd85d', + '#b4e85b', + '#f0f06d', + '#fce46d', + '#fcb36d', + '#f98b6d', + '#ef4444', + ]; + + return chartItems.map((item, index) => ({ + name: item, + count: counts[item] ?? 0, + color: colors[index], + })); + }, [counts]); + const chartConfig: ChartConfig = { + count: { + label: '건수', + }, + }; + + const fetchAllCounts = useCallback(async () => { + if (!dateQuery.from || !dateQuery.to) { + return; + } + + setIsLoading(true); + try { + const countPromises = targetValues.map(async (targetValue) => { + const filters = [ + { + id: 'eventType', + value: targetValue, + type: 'text', + operator: 'iLike', + rowId: '', + } + ]; + + const searchParams = { + filters, + joinOperator: 'and', + from: dateQuery.from, + to: dateQuery.to, + search: dateQuery.search, + flags: [], + page: 1, + perPage: 10, + sort: [{ id: 'createdAt', desc: true }], + }; + + const { count } = await getRisksViewCount(searchParams as any); + return { targetValue, count }; + }); + + const results = await Promise.all(countPromises); + const newCounts: CountData = {}; + results.forEach(({ targetValue, count }) => { + newCounts[targetValue] = count; + }); + + setCounts(newCounts); + } catch (error) { + console.error('리스크 데이터 개수 조회에 실패했어요:', error); + const resetCounts: CountData = {}; + targetValues.forEach(value => { + resetCounts[value] = 0; + }); + setCounts(resetCounts); + } finally { + setIsLoading(false); + } + }, [dateQuery, targetValues]); + + useEffect(() => { + const urlParams = new URLSearchParams(searchParams?.toString()); + const from = urlParams.get('from') ?? defaultDateRange.from?.toISOString() ?? ''; + const to = urlParams.get('to') ?? defaultDateRange.to?.toISOString() ?? ''; + const search = urlParams.get('search') ?? ''; + + setDateQuery((prev) => { + if (prev.from === from && prev.to === to && prev.search === search) { + return prev; + } + return { from, to, search }; + }); + }, [searchParams, defaultDateRange]); + + useEffect(() => { + fetchAllCounts(); + }, [fetchAllCounts]); + + const handleButtonClick = useCallback((targetValue: string) => { + const newFilters = [ + { + id: 'eventType', + value: targetValue, + type: 'text', + operator: 'iLike', + rowId: '', + } + ]; + + const newUrlParams = new URLSearchParams(searchParams?.toString()); + newUrlParams.set('filters', JSON.stringify(newFilters)); + + const baseUrl = `/${language}/evcp/risk-management`; + const fullUrl = `${baseUrl}?${newUrlParams.toString()}`; + const decodedUrl = decodeURIComponent(fullUrl); + + router.push(decodedUrl); + router.refresh(); + }, [searchParams, language, router]); + + return ( + <div className="flex items-center justify-center gap-16"> + <div className="w-3/5 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-x-16 gap-y-4 p-8"> + {targetValues.map((targetValue) => ( + <div key={targetValue} className="w-full"> + <div className="w-32 flex flex-col items-center justify-center gap-2"> + <div className="font-bold">{targetValue}</div> + <Button + className="w-full h-12 text-lg font-bold" + onClick={() => handleButtonClick(targetValue)} + disabled={isLoading} + > + {isLoading ? ( + <LoaderCircle width={16} className="animate-spin" /> + ) : ( + <> + {counts[targetValue] ?? 0}건 + </> + )} + </Button> + </div> + </div> + ))} + </div> + <Card className="w-1/3"> + <CardHeader> + <CardTitle className="text-lg font-semibold">주요 리스크 현황</CardTitle> + </CardHeader> + <CardContent> + {chartData.filter(item => item.count > 0).length === 0 ? ( + <div className="flex items-center justify-center h-[300px] text-gray-500"> + 주요 리스크가 존재하지 않아요. + </div> + ) : ( + <ChartContainer config={chartConfig} className="h-[300px]"> + <BarChart + data={chartData.filter(item => item.count > 0)} + layout="vertical" + margin={{ left: 30, right: 30 }} + > + <XAxis type="number" dataKey="count" hide /> + <YAxis + dataKey="name" + type="category" + tickLine={false} + tickMargin={10} + axisLine={false} + /> + <ChartTooltip + content={<ChartTooltipContent className="flex justify-center" />} + formatter={(value) => [`${value}건`]} + /> + <Bar dataKey="count" radius={[8, 8, 8, 8]}> + <LabelList + position="right" + offset={12} + className="fill-foreground" + fontSize={12} + formatter={(value: ValueType) => [`${value}건`]} + /> + {chartData.map((entry, index) => ( + <Cell key={`cell-${index}`} fill={entry.color} /> + ))} + </Bar> + </BarChart> + </ChartContainer> + )} + </CardContent> + </Card> + </div> + ); +} + +// ---------------------------------------------------------------------------------------------------- + +/* EXPORT */ +export default RisksDashboard; diff --git a/lib/risk-management/table/risks-date-range-picker.tsx b/lib/risk-management/table/risks-date-range-picker.tsx new file mode 100644 index 00000000..96acff6c --- /dev/null +++ b/lib/risk-management/table/risks-date-range-picker.tsx @@ -0,0 +1,157 @@ +'use client'; + +/* IMPORT */ +import { format } from 'date-fns'; +import { Button, type ButtonProps } from '@/components/ui/button'; +import { Calendar } from '@/components/ui/calendar'; +import { CalendarIcon, X } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { parseAsString, useQueryStates } from 'nuqs'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { type ComponentPropsWithoutRef, type MouseEvent, useMemo } from 'react'; +import { type DateRange } from 'react-day-picker'; + +// ---------------------------------------------------------------------------------------------------- + +/* TYPES */ +interface RisksDateRangePickerProps extends ComponentPropsWithoutRef<typeof PopoverContent> { + defaultDateRange?: DateRange; + placeholder?: string; + triggerVariant?: Exclude<ButtonProps['variant'], 'destructive' | 'link'>; + triggerSize?: Exclude<ButtonProps['size'], 'icon'>; + triggerClassName?: string; + shallow?: boolean; + showClearButton?: boolean; +} + +// ---------------------------------------------------------------------------------------------------- + +/* RISKS DATE RANGE PICKER COMPONENT */ +function RisksDateRangePicker(props: RisksDateRangePickerProps) { + const { + defaultDateRange, + placeholder = '날짜를 선택하세요.', + triggerVariant = 'outline', + triggerSize = 'default', + triggerClassName, + showClearButton = false, + shallow = true, + className, + ...otherProps + } = props; + const [dateParams, setDateParams] = useQueryStates( + { + from: parseAsString.withDefault( + defaultDateRange?.from?.toISOString() ?? '' + ), + to: parseAsString.withDefault(defaultDateRange?.to?.toISOString() ?? ''), + }, + { + clearOnDefault: true, + shallow, + } + ) + + const date = useMemo(() => { + function parseDate(dateString: string | null) { + if (!dateString) { + return undefined; + } + const parsedDate = new Date(dateString); + return isNaN(parsedDate.getTime()) ? undefined : parsedDate; + } + + return { + from: parseDate(dateParams.from) ?? defaultDateRange?.from, + to: parseDate(dateParams.to) ?? defaultDateRange?.to, + }; + }, [dateParams, defaultDateRange]); + + const clearDates = (e: MouseEvent) => { + e.stopPropagation(); + void setDateParams({ + from: "", + to: "", + }); + }; + + const hasSelectedDate = Boolean(date?.from || date?.to); + + return ( + <div className="grid gap-2"> + <Popover> + <PopoverTrigger asChild> + <Button + variant={triggerVariant} + size={triggerSize} + className={cn( + 'relative w-full justify-start gap-2 truncate text-left font-normal', + !date && 'text-muted-foreground', + triggerClassName + )} + > + <CalendarIcon className="size-4" /> + {date?.from ? ( + date.to ? ( + <> + {format(date.from, 'LLL dd, y')} -{' '} + {format(date.to, 'LLL dd, y')} + </> + ) : ( + format(date.from, 'LLL dd, y') + ) + ) : ( + <span>{placeholder}</span> + )} + + {showClearButton && hasSelectedDate && ( + <Button + type="button" + variant="ghost" + size="icon" + className="absolute right-0 top-0 h-full rounded-l-none px-3 hover:bg-background" + onClick={clearDates} + > + <X className="size-4" /> + <span className="sr-only">초기화</span> + </Button> + )} + </Button> + </PopoverTrigger> + <PopoverContent className={cn("w-auto p-0", className)} {...otherProps}> + <Calendar + initialFocus + mode="range" + defaultMonth={date?.from} + selected={date?.from && date?.to ? date : undefined} + onSelect={(newDateRange) => { + const from = newDateRange?.from; + const to = newDateRange?.to; + + if (from && to && from > to) { + void setDateParams({ + from: to.toISOString(), + to: from.toISOString(), + }); + } else { + void setDateParams({ + from: from?.toISOString() ?? '', + to: to?.toISOString() ?? '', + }); + } + }} + numberOfMonths={2} + /> + </PopoverContent> + </Popover> + </div> + ) +} + +// ---------------------------------------------------------------------------------------------------- + +export default RisksDateRangePicker; diff --git a/lib/risk-management/table/risks-mail-dialog.tsx b/lib/risk-management/table/risks-mail-dialog.tsx new file mode 100644 index 00000000..8bee1191 --- /dev/null +++ b/lib/risk-management/table/risks-mail-dialog.tsx @@ -0,0 +1,560 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +'use client'; + +/* IMPORT */ +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Checkbox } from '@/components/ui/checkbox'; +import { ChevronsUpDown, X } from 'lucide-react'; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/components/ui/collapsible'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog'; +import { + Dropzone, + DropzoneDescription, + DropzoneInput, + DropzoneTitle, + DropzoneUploadIcon, + DropzoneZone, +} from '@/components/ui/dropzone'; +import { + FileList, + FileListAction, + FileListDescription, + FileListHeader, + FileListIcon, + FileListInfo, + FileListItem, + FileListName, + FileListSize, +} from '@/components/ui/file-list'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { format } from 'date-fns'; +import { getProcurementManagerList, modifyRiskEvents } from '../service'; +import { RISK_ADMIN_COMMENTS_LIST } from '@/config/risksConfig'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Textarea } from '@/components/ui/textarea'; +import { toast } from 'sonner'; +import { type RisksView, type User } from '@/db/schema'; +import { useForm } from 'react-hook-form'; +import { useEffect, useMemo, useState, useTransition } from 'react'; +import UserComboBox from './user-combo-box'; +import { z } from 'zod'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { se } from 'date-fns/locale'; + +// ---------------------------------------------------------------------------------------------------- + +/* TYPES */ +const risksMailFormSchema = z.object({ + managerId: z.number({ required_error: '구매 담당자를 반드시 선택해야 해요.' }), + adminComment: z.string().min(1, { message: '구매 담당자 의견을 반드시 작성해야 해요.' }), + attachment: z + .instanceof(File) + .refine((file) => file.size <= 10485760, { + message: '파일 크기는 10MB를 초과할 수 없어요.', + }) + .optional(), +}); + +type RisksMailFormData = z.infer<typeof risksMailFormSchema>; + +interface RisksMailDialogProps { + open: boolean, + onOpenChange: (open: boolean) => void, + riskDataList: RisksView[], + onSuccess: () => void, +}; + +// ---------------------------------------------------------------------------------------------------- + +/* CONSTATNS */ +const ALWAYS_CHECKED_TYPES = ['종합등급', '신용등급', '현금흐름등급', 'WATCH등급']; + +// ---------------------------------------------------------------------------------------------------- + +/* RISKS MAIL DIALOG COPONENT */ +function RisksMailDialog(props: RisksMailDialogProps) { + const { open, onOpenChange, riskDataList, onSuccess } = props; + const riskDataMap = useMemo(() => { + return riskDataList.reduce((acc, item) => { + if (!acc[item.vendorId]) { + acc[item.vendorId] = []; + } + acc[item.vendorId].push(item); + return acc; + }, {} as Record<string, typeof riskDataList>); + }, [riskDataList]); + const [isPending, startTransition] = useTransition(); + const form = useForm<RisksMailFormData>({ + resolver: zodResolver(risksMailFormSchema), + defaultValues: { + managerId: undefined, + adminComment: '', + attachment: undefined, + }, + }); + const selectedFile = form.watch('attachment'); + const [selectedVendorId, setSelectedVendorId] = useState<number | null>(riskDataList[0]?.vendorId ?? null); + const [selectedCommentType, setSelectedCommentType] = useState(''); + const [managerList, setManagerList] = useState<Partial<User>[]>([]); + const [isLoadingManagerList, setIsLoadingManagerList] = useState(false); + const [riskCheckMap, setRiskCheckMap] = useState<Record<string, boolean>>({}); + + useEffect(() => { + if (!selectedVendorId) { + return; + } + + const eventTypeMap = (riskDataMap[selectedVendorId] || []).reduce<Record<string, RisksView[]>>((acc, item) => { + if (!acc[item.eventType]) { + acc[item.eventType] = []; + } + acc[item.eventType].push(item); + return acc; + }, {}); + + const initialRiskCheckMap: Record<string, boolean> = {}; + Object.keys(eventTypeMap).forEach((type) => { + initialRiskCheckMap[type] = true; + }); + + setRiskCheckMap(initialRiskCheckMap); + setSelectedCommentType('기타'); + form.reset({ + managerId: undefined, + adminComment: '', + attachment: undefined, + }); + }, [open, selectedVendorId]); + + useEffect(() => { + if (open) { + startTransition(async () => { + try { + setIsLoadingManagerList(true); + form.reset({ + managerId: undefined, + adminComment: '', + attachment: undefined, + }); + setSelectedCommentType('기타'); + const managerList = await getProcurementManagerList(); + setManagerList(managerList); + } catch (error) { + console.error('Error in Loading Risk Event for Managing:', error); + toast.error(error instanceof Error ? error.message : '구매 담당자 목록을 불러오는 데 실패했어요.'); + } finally { + setIsLoadingManagerList(false); + } + }); + } + }, [open, form]); + + const formatBusinessNumber = (numberString: string) => { + if (!numberString) { + return '정보 없음'; + } + return /^\d{10}$/.test(numberString) + ? `${numberString.slice(0, 3)}-${numberString.slice(3, 5)}-${numberString.slice(5)}` + : numberString; + }; + + const handleCheckboxChange = (type: string) => { + if (ALWAYS_CHECKED_TYPES.includes(type)) { + return; + } + setRiskCheckMap(prev => ({ + ...prev, + [type]: !prev[type], + })); + }; + + const handleFileChange = (files: File[]) => { + if (files.length === 0) { + return; + } + const file = files[0]; + const maxFileSize = 10 * 1024 * 1024 + if (file.size > maxFileSize) { + toast.error('파일 크기는 10MB를 초과할 수 없어요.'); + return; + } + form.setValue('attachment', file); + form.clearErrors('attachment'); + } + + const removeFile = () => { + form.resetField('attachment'); + } + + const onSubmit = async (data: RisksMailFormData) => { + startTransition(async () => { + try { + if (!selectedVendorId) { + throw Error('선택된 협력업체가 존재하지 않아요.'); + } + + const newRiskEventData = { + managerId: data.managerId , + adminComment: data.adminComment, + }; + + await Promise.all( + (riskDataMap[selectedVendorId] ?? []).map(riskEvent => + modifyRiskEvents(riskEvent.id, newRiskEventData) + ) + ); + + const eventTypeMap = (riskDataMap[selectedVendorId] || []).reduce<Record<string, RisksView[]>>((acc, item) => { + if (!acc[item.eventType]) { + acc[item.eventType] = []; + } + acc[item.eventType].push(item); + return acc; + }, {}); + const filteredEventTypeMap: Record<string, RisksView[]> = {}; + Object.entries(eventTypeMap).forEach(([type, items]) => { + if (ALWAYS_CHECKED_TYPES.includes(type)) { + return; + } + if (riskCheckMap[type]) { + filteredEventTypeMap[type] = items; + } + }); + + const formData = new FormData(); + formData.append('vendorId', String(selectedVendorId)); + formData.append('managerId', String(data.managerId)); + formData.append('adminComment', data.adminComment); + if (data.attachment) { + formData.append('attachment', data.attachment); + } + formData.append('selectedEventTypeMap', JSON.stringify(filteredEventTypeMap)); + + const res = await fetch('/api/risks/send-risk-email', { + method: 'POST', + body: formData, + }); + + if (!res.ok) { + const errorData = await res.json(); + throw new Error(errorData.message || '리스크 알림 메일 전송에 실패했어요.'); + } + + toast.success('리스크 알림 메일이 구매 담당자에게 발송되었어요.'); + onSuccess(); + } catch (error) { + console.error('Error in Saving Risk Event:', error); + toast.error( + error instanceof Error ? error.message : '리스크 알림 메일 발송 중 오류가 발생했어요.', + ); + } + }) + } + + if (!open) { + return null; + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-4xl max-h-[90vh] flex flex-col"> + <DialogHeader className="flex-shrink-0"> + <DialogTitle className="font-bold"> + 리스크 알림 메일 발송 + </DialogTitle> + <DialogDescription> + 구매 담당자에게 리스크 알림 메일을 발송합니다. + </DialogDescription> + </DialogHeader> + <Tabs + value={selectedVendorId !== null ? String(selectedVendorId) : undefined} + onValueChange={(value) => setSelectedVendorId(value ? Number(value) : null)} + className="mb-4" + > + <TabsList> + {Object.entries(riskDataMap).map(([vendorId, items]) => ( + <TabsTrigger key={vendorId} value={vendorId}> + {items[0].vendorName} + </TabsTrigger> + ))} + </TabsList> + {Object.entries(riskDataMap).map(([vendorId, items]) => ( + <TabsContent key={vendorId} value={vendorId} className="overflow-y-auto max-h-[60vh]"> + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col flex-1 min-h-0"> + <ScrollArea className="flex-1 pr-4 overflow-y-auto"> + <div className="space-y-6"> + <Card className="w-full"> + <CardHeader> + <CardTitle>협력업체 정보</CardTitle> + </CardHeader> + <CardContent className="space-y-4 text-sm text-muted-foreground"> + <div className="grid grid-cols-2 gap-4"> + <div> + <span className="font-medium text-foreground">협력업체명: </span> + {items[0].vendorName ?? '정보 없음'} + </div> + <div> + <span className="font-medium text-foreground">사업자등록번호: </span> + {formatBusinessNumber(items[0].businessNumber ?? '')} + </div> + <div> + <span className="font-medium text-foreground">협력업체 코드: </span> + {items[0].vendorCode ?? '정보 없음'} + </div> + </div> + </CardContent> + </Card> + <Card className="w-full"> + <CardHeader> + <CardTitle>리스크 정보</CardTitle> + <CardDescription>메일로 전송할 리스크 정보를 선택하세요.</CardDescription> + </CardHeader> + <CardContent className="space-y-4"> + {Object.entries( + items?.reduce<Record<string, typeof items>>((acc, item) => { + if (!acc[item.eventType]) acc[item.eventType] = []; + acc[item.eventType].push(item); + return acc; + }, {}) || {} + ).map(([eventType, groupedItems]) => ( + <Collapsible key={eventType} defaultOpen={false} className="rounded-md border gap-2 p-4 text-sm"> + <div className="flex items-center justify-between gap-4 px-4"> + <div className="flex items-center gap-2"> + <Checkbox + checked={riskCheckMap[eventType]} + disabled={ALWAYS_CHECKED_TYPES.includes(eventType)} + onCheckedChange={() => handleCheckboxChange(eventType)} + /> + <span className="text-sm font-semibold">{eventType}</span> + </div> + <CollapsibleTrigger className="flex justify-between items-center"> + <Button type="button" variant="ghost" size="icon" className="size-8"> + <ChevronsUpDown /> + </Button> + </CollapsibleTrigger> + </div> + <CollapsibleContent> + {/* Table로 변경할 것 */} + <div className="flex items-center justify-between rounded-md border my-2 px-4 py-2 text-sm"> + <div className="font-bold">신용평가사</div> + <div className="font-bold">상세 내용</div> + <div className="font-bold">발생일자</div> + </div> + {groupedItems.map(item => ( + <div key={item.id} className="flex items-center justify-between rounded-md border gap-2 px-4 py-2 text-sm"> + <Badge variant="secondary">{item.provider}</Badge> + <div className="text-sm text-muted-foreground">{item.content}</div> + <div>{format(item.occuredAt, 'yyyy-MM-dd')}</div> + </div> + ))} + </CollapsibleContent> + </Collapsible> + ))} + </CardContent> + </Card> + <Card className="w-full"> + <CardHeader> + <CardTitle>메일 발송 정보</CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + <div className="grid grid-cols-2 gap-4"> + <FormField + control={form.control} + name="managerId" + render={({ field }) => ( + <FormItem> + <FormLabel>구매 담당자</FormLabel> + <UserComboBox + users={managerList} + value={field.value ?? null} + onChange={field.onChange} + placeholder={isLoadingManagerList ? '구매 담당자 로딩 중...' : '구매 담당자 선택...'} + disabled={isPending || isLoadingManagerList} + /> + <FormControl> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <div className="flex flex-col gap-4"> + <FormItem> + <FormLabel>관리 담당자 의견</FormLabel> + <FormControl> + <Select + onValueChange={(value) => { + setSelectedCommentType(value); + if (value !== '기타') { + form.setValue('adminComment', value); + } else { + form.setValue('adminComment', ''); + } + }} + value={selectedCommentType} + > + <SelectTrigger> + <SelectValue placeholder="의견 선택" /> + </SelectTrigger> + <SelectContent> + {RISK_ADMIN_COMMENTS_LIST.map((comment) => ( + <SelectItem key={comment} value={comment}> + {comment} + </SelectItem> + ))} + </SelectContent> + </Select> + </FormControl> + </FormItem> + {selectedCommentType === '기타' && ( + <FormField + control={form.control} + name="adminComment" + render={({ field }) => ( + <FormItem> + <FormControl> + <Textarea + placeholder="관리 담당자 의견을 입력하세요." + {...field} + value={field.value ?? ''} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + )} + </div> + </div> + <FormField + control={form.control} + name="attachment" + render={() => ( + <FormItem> + <FormLabel>첨부파일</FormLabel> + <FormControl> + <div className="space-y-3"> + <Dropzone + onDrop={(acceptedFiles) => { + handleFileChange(acceptedFiles) + }} + accept={{ + 'application/pdf': ['.pdf'], + 'application/msword': ['.doc'], + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'], + 'application/vnd.ms-excel': ['.xls'], + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'], + 'application/vnd.ms-powerpoint': ['.ppt'], + 'application/vnd.openxmlformats-officedocument.presentationml.presentation': ['.pptx'], + 'application/zip': ['.zip'], + 'application/x-rar-compressed': ['.rar'] + }} + maxSize={10 * 1024 * 1024} + multiple={false} + disabled={isPending} + > + <DropzoneZone className="flex flex-col items-center gap-2"> + <DropzoneUploadIcon /> + <DropzoneTitle>클릭하여 파일 선택 또는 드래그 앤 드롭</DropzoneTitle> + <DropzoneDescription> + PDF, DOC, XLS, PPT 등 (최대 10MB, 파일 1개) + </DropzoneDescription> + <DropzoneInput /> + </DropzoneZone> + </Dropzone> + {selectedFile && ( + <div className="space-y-2"> + <FileListHeader> + 선택된 파일 + </FileListHeader> + <FileList> + <FileListItem className="flex items-center justify-between gap-3"> + <FileListIcon /> + <FileListInfo> + <FileListName>{selectedFile.name}</FileListName> + <FileListDescription> + <FileListSize>{selectedFile.size}</FileListSize> + </FileListDescription> + </FileListInfo> + <FileListAction + onClick={removeFile} + disabled={isPending} + > + <X className="h-4 w-4" /> + </FileListAction> + </FileListItem> + </FileList> + </div> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </CardContent> + </Card> + </div> + </ScrollArea> + <DialogFooter className="flex-shrink-0 mt-4 pt-4 border-t"> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isPending} + > + 취소 + </Button> + <Button type="submit" disabled={isPending || isLoadingManagerList}> + {isLoadingManagerList ? '로딩 중...' : isPending ? '저장 중...' : '메일 발송'} + </Button> + </DialogFooter> + </form> + </Form> + </TabsContent> + ))} + </Tabs> + </DialogContent> + </Dialog> + ) +} + +// ---------------------------------------------------------------------------------------------------- + +/* EXPORT */ +export default RisksMailDialog; diff --git a/lib/risk-management/table/risks-table-toolbar-actions.tsx b/lib/risk-management/table/risks-table-toolbar-actions.tsx new file mode 100644 index 00000000..2d4ba2d4 --- /dev/null +++ b/lib/risk-management/table/risks-table-toolbar-actions.tsx @@ -0,0 +1,161 @@ +'use client'; + +/* IMPORT */ +import { Button } from '@/components/ui/button'; +import { ChangeEvent, useRef } from 'react'; +import { Download, FileInput, Mail, Upload } from 'lucide-react'; +import { exportTableToExcel } from '@/lib/export'; +import { generateRiskEventsTemplate, importRiskEventsExcel } from '../service'; +import { toast } from 'sonner'; +import { type DataTableRowAction } from '@/types/table'; +import { type RisksView } from '@/db/schema'; +import { type Table } from '@tanstack/react-table'; + +// ---------------------------------------------------------------------------------------------------- + +/* TYPES */ +interface RisksTableToolbarActionsProps { + table: Table<RisksView>; + onOpenMailDialog: () => void; + onRefresh: () => void; +} + +// ---------------------------------------------------------------------------------------------------- + +/* RISKS TABLE TOOLBAR ACTIONS COMPONENT */ +function RisksTableToolbarActions(props: RisksTableToolbarActionsProps) { + const { table, onOpenMailDialog, onRefresh } = props; + const selectedRows = table.getFilteredSelectedRowModel().rows; + const hasSelection = selectedRows.length > 0; + const fileInputRef = useRef<HTMLInputElement>(null); + + // EXCEL IMPORT + function handleImport() { + fileInputRef.current?.click(); + }; + async function onFileChange(event: ChangeEvent<HTMLInputElement>) { + const file = event.target.files?.[0]; + if (!file) { + toast.error('가져올 파일을 선택해주세요.'); + return; + } + if (!file.name.endsWith('.xlsx') && !file.name.endsWith('.xls')) { + toast.error('.xlsx 또는 .xls 확장자인 Excel 파일만 업로드 가능해요.'); + return; + } + event.target.value = ''; + + try { + const { errorFile, errorMessage, successMessage } = await importRiskEventsExcel(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(successMessage || 'Excel 파일이 성공적으로 업로드되었어요.'); + } + } catch (error) { + toast.error('Excel 파일을 업로드하는 중 오류가 발생했습니다.'); + console.error('Error in Excel File Upload: ', error); + } finally { + onRefresh(); + } + }; + + // EXCEL EXPORT + const handleExport = async () => { + try { + exportTableToExcel(table, { + filename: '협력업체_리스크_관리', + excludeColumns: ['id', 'actions'], + }); + toast.success('Excel 파일이 다운로드되었어요.'); + } catch (error) { + console.error('Error in Exporting to Excel: ', error); + toast.error('Excel 파일 내보내기 중 오류가 발생했어요.'); + } + }; + + // EXCEL TEMPLATE DOWNLOAD + const handleTemplateDownload = async () => { + try { + const buffer = await generateRiskEventsTemplate(); + const blob = new Blob([buffer], { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = "협력업체_리스크_템플릿.xlsx"; + link.click(); + URL.revokeObjectURL(url); + toast.success('템플릿 파일이 다운로드되었어요.'); + } catch (error) { + console.error('Error in Template Download: ', error); + toast.error('템플릿 다운로드 중 오류가 발생했어요.'); + } + }; + + return ( + <div className="flex items-center gap-2"> + <Button + size="sm" + className="gap-2" + onClick={onOpenMailDialog} + disabled={!hasSelection} + > + <Mail className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline"> + 메일 발송 + </span> + </Button> + <Button + variant="outline" + size="sm" + className="gap-2" + onClick={handleImport} + > + <Upload className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Import</span> + </Button> + <input + ref={fileInputRef} + type="file" + accept=".xlsx,.xls" + className="hidden" + onChange={onFileChange} + /> + <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> + <Button + variant="outline" + size="sm" + onClick={handleTemplateDownload} + className="gap-2" + > + <FileInput className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Template</span> + </Button> + </div> + ); +} + +// ---------------------------------------------------------------------------------------------------- + +/* EXPORT */ +export default RisksTableToolbarActions;
\ No newline at end of file diff --git a/lib/risk-management/table/risks-table.tsx b/lib/risk-management/table/risks-table.tsx new file mode 100644 index 00000000..d6317c26 --- /dev/null +++ b/lib/risk-management/table/risks-table.tsx @@ -0,0 +1,176 @@ +'use client'; + +/* IMPORT */ +import { DataTable } from '@/components/data-table/data-table'; +import { DataTableAdvancedToolbar } from '@/components/data-table/data-table-advanced-toolbar'; +import getColumns from './risks-columns'; +import { getRisksView } from '../service'; +import { type RisksView } from '@/db/schema'; +import RisksMailDialog from './risks-mail-dialog'; +import RisksTableToolbarActions from './risks-table-toolbar-actions'; +import RisksUpdateSheet from './risks-update-sheet'; +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'; +import { RISK_EVENT_TYPE_LIST, RISK_PROVIDER_LIST } from '@/config/risksConfig'; + +// ---------------------------------------------------------------------------------------------------- + +/* TYPES */ +interface RisksTableProps { + promises: Promise<[ + Awaited<ReturnType<typeof getRisksView>>, + ]>; +} + +// ---------------------------------------------------------------------------------------------------- + +/* TABLE COMPONENT */ +function RisksTable({ promises }: RisksTableProps) { + const router = useRouter(); + const [rowAction, setRowAction] = useState<DataTableRowAction<RisksView> | null>(null); + const [isMailDialogOpen, setIsMailDialogOpen] = useState(false); + const [promiseData] = use(promises); + const tableData = promiseData; + const columns = useMemo( + () => getColumns({ setRowAction }), + [setRowAction], + ); + + const filterFields: DataTableFilterField<RisksView>[] = [ + { + id: 'eventType', + label: '리스크 항목', + placeholder: '리스크 항목 선택...', + }, + { + id: 'vendorName', + label: '협력업체명', + placeholder: '협력업체명 입력...', + }, + { + id: 'businessNumber', + label: '사업자등록번호', + placeholder: '사업자등록번호 입력...', + }, + { + id: 'provider', + label: '신용평가사', + placeholder: '신용평가사 선택...', + }, + ] + const advancedFilterFields: DataTableAdvancedFilterField<RisksView>[] = [ + { + id: 'eventType', + label: '리스크 항목', + type: 'select', + options: RISK_EVENT_TYPE_LIST.map((item: string) => ({ + label: item, + value: item, + })), + }, + { + id: 'provider', + label: '신용평가사', + type: 'select', + options: RISK_PROVIDER_LIST.map((item: string) => ({ + label: item, + value: item, + })), + }, + { + id: 'vendorName', + label: '협력업체명', + type: 'text', + }, + { + id: 'content', + label: '상세 내용', + type: 'text', + }, + ]; + + // Data Table Setting + const { table } = useDataTable({ + data: tableData.data, + columns, + pageCount: tableData.pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [ + { id: 'occuredAt', desc: true }, + ], + columnPinning: { left: ['select'], right: ['actions'] }, + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + }); + + const emptyRiskData: RisksView = { + id: 0, + vendorName: '', + vendorCode: '', + vendorId: 0, + businessNumber: '', + provider: '', + eventType: '', + content: '', + eventStatus: true, + managerId: 0, + managerName: '', + adminComment: '', + occuredAt: new Date(), + }; + + const refreshData = useCallback(() => { + router.refresh(); + }, [router]); + + const handleModifySuccess = useCallback(() => { + setRowAction(null); + refreshData(); + }, [refreshData]); + + return ( + <> + <DataTable table={table}> + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <RisksTableToolbarActions + table={table} + onOpenMailDialog={() => setIsMailDialogOpen(true)} + onRefresh={refreshData} + /> + </DataTableAdvancedToolbar> + </DataTable> + <RisksUpdateSheet + open={rowAction?.type === 'update'} + onOpenChange={() => setRowAction(null)} + riskData={rowAction?.row.original ?? emptyRiskData} + onSuccess={handleModifySuccess} + /> + <RisksMailDialog + open={isMailDialogOpen} + onOpenChange={setIsMailDialogOpen} + riskDataList={table.getFilteredSelectedRowModel().rows?.map(row => row.original) ?? []} + onSuccess={refreshData} + /> + </> + ) +} + +// ---------------------------------------------------------------------------------------------------- + +/* EXPORT */ +export default RisksTable;
\ No newline at end of file diff --git a/lib/risk-management/table/risks-update-sheet.tsx b/lib/risk-management/table/risks-update-sheet.tsx new file mode 100644 index 00000000..727a7634 --- /dev/null +++ b/lib/risk-management/table/risks-update-sheet.tsx @@ -0,0 +1,406 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +'use client'; + +/* IMPORT */ +import { Button } from '@/components/ui/button'; +import { Calendar } from '@/components/ui/calendar'; +import { CalendarIcon } from 'lucide-react'; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { format } from 'date-fns'; +import { getProcurementManagerList, getRiskEventsById, modifyRiskEvents } from '../service'; +import { ko } from 'date-fns/locale'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { RISK_ADMIN_COMMENTS_LIST, RISK_EVENT_TYPE_LIST, RISK_PROVIDER_LIST } from '@/config/risksConfig'; +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 { type RisksView, type User } from '@/db/schema'; +import { useEffect, useState, useTransition } from 'react'; +import { useForm } from 'react-hook-form'; +import UserComboBox from './user-combo-box'; +import { z } from 'zod'; +import { zodResolver } from '@hookform/resolvers/zod'; + +// ---------------------------------------------------------------------------------------------------- + +/* TYPES */ +const risksUpdateFormSchema = z.object({ + eventType: z.enum(RISK_EVENT_TYPE_LIST as [string, ...string[]]), + provider: z.enum(RISK_PROVIDER_LIST as [string, ...string[]]), + occuredAt: z.date(), + content: z.string().optional(), + eventStatus: z.boolean(), + managerId: z.number().optional(), + adminComment: z.string().optional(), +}); + +type RisksUpdateFormData = z.infer<typeof risksUpdateFormSchema>; + +interface RisksUpdateSheetProps { + open: boolean, + onOpenChange: (open: boolean) => void, + riskData: RisksView, + onSuccess: () => void, +}; + +// ---------------------------------------------------------------------------------------------------- + +/* RISKS UPDATE FORM SHEET COMPONENT */ +function RisksUpdateSheet(props: RisksUpdateSheetProps) { + const { + open, + onOpenChange, + riskData, + onSuccess, + } = props; + const [isPending, startTransition] = useTransition(); + const form = useForm<RisksUpdateFormData>({ + resolver: zodResolver(risksUpdateFormSchema), + defaultValues: { + eventType: '', + provider: '', + occuredAt: new Date(), + content: '', + eventStatus: true, + managerId: undefined, + adminComment: '', + }, + }); + const watchEventStatus = form.watch('eventStatus'); + const [selectedCommentType, setSelectedCommentType] = useState(''); + const [managerList, setManagerList] = useState<Partial<User>[]>([]); + const [isLoadingManagerList, setIsLoadingManagerList] = useState(false); + + useEffect(() => { + if (open && riskData?.id) { + startTransition(async () => { + try { + const targetData = await getRiskEventsById(riskData.id); + if (targetData) { + const targetRiskEvent = targetData[0]; + form.reset({ + eventType: targetRiskEvent.eventType, + provider: targetRiskEvent.provider, + occuredAt: targetRiskEvent.occuredAt, + content: targetRiskEvent.content ?? '', + eventStatus: targetRiskEvent.eventStatus, + managerId: targetRiskEvent.managerId || undefined, + adminComment: targetRiskEvent.adminComment ?? '', + }); + setSelectedCommentType( + RISK_ADMIN_COMMENTS_LIST.includes(targetRiskEvent.adminComment ?? '') ? targetRiskEvent.adminComment! : '기타', + ); + const managerList = await getProcurementManagerList(); + setManagerList(managerList); + } + } catch (error) { + console.error('Error in Loading Risk Event for Updating:', error); + toast.error(error instanceof Error ? error.message : '편집할 데이터를 불러오는 데 실패했어요.'); + } finally { + setIsLoadingManagerList(false); + } + }); + } + }, [open, form]); + + const onSubmit = async (data: RisksUpdateFormData) => { + startTransition(async () => { + try { + const newRiskEventData = { + eventType: data.eventType, + provider: data.provider, + occuredAt: data.occuredAt, + content: data.content || null, + eventStatus: data.eventStatus, + managerId: !data.eventStatus ? null : data.managerId === 0 ? null : data.managerId, + adminComment: !data.eventStatus ? null : data.adminComment || null, + }; + await modifyRiskEvents(riskData.id, newRiskEventData); + toast.success('리스크 이벤트가 수정되었어요.'); + onSuccess(); + onOpenChange(false); + } catch (error) { + console.error('Error in Saving Risk Event:', 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] flex flex-col" style={{width:900, height: '100vh'}}> + <SheetHeader className="flex-shrink-0 pb-4 border-b"> + <SheetTitle className="font-bold"> + 리스크 정보 관리 + </SheetTitle> + <SheetDescription> + 리스크 정보를 수정할 수 있어요. + </SheetDescription> + </SheetHeader> + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col flex-1 min-h-0"> + <div className="flex-1 overflow-y-auto py-4 min-h-0"> + <div className="space-y-6 pr-4"> + <Card> + <CardHeader> + <CardTitle>기본 정보</CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + <div className="grid grid-cols-2 gap-4"> + <FormField + control={form.control} + name="eventType" + render={({ field }) => ( + <FormItem> + <FormLabel>항목</FormLabel> + <FormControl> + <Select onValueChange={field.onChange} value={field.value || ""}> + <SelectTrigger> + <SelectValue placeholder="선택" /> + </SelectTrigger> + <SelectContent> + {RISK_EVENT_TYPE_LIST.map((option) => ( + <SelectItem key={option} value={option}> + {option} + </SelectItem> + ))} + </SelectContent> + </Select> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="provider" + render={({ field }) => ( + <FormItem> + <FormLabel>신용평가사</FormLabel> + <FormControl> + <Select onValueChange={field.onChange} value={field.value || ""}> + <SelectTrigger> + <SelectValue placeholder="선택" /> + </SelectTrigger> + <SelectContent> + {RISK_PROVIDER_LIST.map((option) => ( + <SelectItem key={option} value={option}> + {option} + </SelectItem> + ))} + </SelectContent> + </Select> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="occuredAt" + render={({ field }) => ( + <FormItem className="flex flex-col"> + <FormLabel>발생일자</FormLabel> + <Popover> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + className={`pl-3 text-left font-normal ${!field.value && "text-muted-foreground"}`} + > + {field.value + ? format(field.value, "yyyy-MM-dd", { locale: ko }) + : "날짜 선택"} + <CalendarIcon className="ml-auto h-4 w-4 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-auto p-0" align="start"> + <Calendar + mode="single" + selected={field.value ?? undefined} + onSelect={(date) => field.onChange(date || undefined)} + locale={ko} + /> + </PopoverContent> + </Popover> + <FormMessage /> + </FormItem> + )} + /> + </div> + <FormField + control={form.control} + name="content" + render={({ field }) => ( + <FormItem> + <FormLabel>상세 내용</FormLabel> + <FormControl> + <Textarea + placeholder="상세 내용을 입력하세요." + {...field} + value={field.value ?? ''} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </CardContent> + </Card> + <Card className="w-full"> + <CardHeader> + <CardTitle>관리 정보</CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + <FormField + control={form.control} + name="eventStatus" + render={({ field }) => ( + <FormItem> + <FormLabel>리스크 해소 여부</FormLabel> + <FormControl> + <Select onValueChange={(value) => field.onChange(value === 'true')} value={String(field.value)}> + <SelectTrigger> + <SelectValue placeholder="리스크 해소 여부 선택" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="true">아니오</SelectItem> + <SelectItem value="false">예</SelectItem> + </SelectContent> + </Select> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + {watchEventStatus && ( + <div className="grid grid-cols-2 gap-4"> + <FormField + control={form.control} + name="managerId" + render={({ field }) => ( + <FormItem> + <FormLabel>구매 담당자</FormLabel> + <UserComboBox + users={managerList} + value={field.value ?? null} + onChange={field.onChange} + placeholder={isLoadingManagerList ? '구매 담당자 로딩 중...' : '구매 담당자 선택...'} + disabled={isPending || isLoadingManagerList} + /> + <FormControl> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <div className="flex flex-col gap-4"> + <FormItem> + <FormLabel>관리 담당자 의견</FormLabel> + <FormControl> + <Select + onValueChange={(value) => { + setSelectedCommentType(value); + if (value !== '기타') { + form.setValue('adminComment', value); + } else { + form.setValue('adminComment', ''); + } + }} + value={selectedCommentType} + > + <SelectTrigger> + <SelectValue placeholder="의견 선택" /> + </SelectTrigger> + <SelectContent> + {RISK_ADMIN_COMMENTS_LIST.map((comment) => ( + <SelectItem key={comment} value={comment}> + {comment} + </SelectItem> + ))} + </SelectContent> + </Select> + </FormControl> + </FormItem> + {selectedCommentType === '기타' && ( + <FormField + control={form.control} + name="adminComment" + render={({ field }) => ( + <FormItem> + <FormControl> + <Textarea + placeholder="관리 담당자 의견을 입력하세요." + {...field} + value={field.value ?? ''} + /> + </FormControl> + </FormItem> + )} + /> + )} + </div> + </div> + )} + </CardContent> + </Card> + </div> + </div> + <div className="flex-shrink-0 flex justify-end gap-2 bg-background"> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isPending} + > + 취소 + </Button> + <Button type="submit" disabled={isPending}> + {isPending ? '저장 중...' : '수정'} + </Button> + </div> + </form> + </Form> + </SheetContent> + </Sheet> + ) +} + +// ---------------------------------------------------------------------------------------------------- + +/* EXPORT */ +export default RisksUpdateSheet; diff --git a/lib/risk-management/table/user-combo-box.tsx b/lib/risk-management/table/user-combo-box.tsx new file mode 100644 index 00000000..e319b538 --- /dev/null +++ b/lib/risk-management/table/user-combo-box.tsx @@ -0,0 +1,127 @@ +'use client'; + +/* IMPORT */ +import { Button } from '@/components/ui/button'; +import { Check, ChevronsUpDown } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, +} from '@/components/ui/command'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { useMemo, useState } from 'react'; +import { User } from '@/db/schema'; + +// ---------------------------------------------------------------------------------------------------- + +/* TYPES */ +interface UserComboBoxProps { + users: Partial<User>[]; + value: number | null; + onChange: (value: number) => void; + placeholder?: string; + disabled?: boolean; +} + +// ---------------------------------------------------------------------------------------------------- + +/* User Combo Box Component */ +function UserComboBox(props: UserComboBoxProps) { + const { + users, + value, + onChange, + placeholder = '담당자 선택...', + disabled = false, + } = props; + const [open, setOpen] = useState(false); + const [inputValue, setInputValue] = useState(''); + const selectedUser = useMemo(() => { + return users.find(user => user.id === value) + }, [users, value]); + + return ( + <Popover open={open} onOpenChange={setOpen}> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={open} + className={cn( + "w-full justify-between", + !value && "text-muted-foreground" + )} + disabled={disabled} + > + {selectedUser ? ( + <span className="flex items-center"> + <span className="font-medium">{selectedUser.name}</span> + {selectedUser.deptName && ( + <span className="ml-2 text-xs text-muted-foreground"> + ({selectedUser.deptName}) + </span> + )} + </span> + ) : ( + placeholder + )} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-[300px] p-0"> + <Command> + <CommandInput + placeholder="담당자 검색..." + value={inputValue} + onValueChange={setInputValue} + /> + <CommandEmpty>검색 결과가 존재하지 않아요.</CommandEmpty> + <CommandGroup className="max-h-[200px] overflow-y-auto"> + {users.map((user) => ( + <CommandItem + key={user.id} + value={user.name} + onSelect={() => { + onChange(user.id!) + setOpen(false) + }} + > + <Check + className={cn( + 'mr-2 h-4 w-4', + value === user.id ? 'opacity-100' : 'opacity-0', + )} + /> + <div className="flex flex-col truncate"> + <div className="flex items-center"> + <span className="font-medium">{user.name}</span> + {user.deptName && ( + <span className="ml-2 text-xs text-muted-foreground"> + ({user.deptName}) + </span> + )} + </div> + <span className="text-xs text-muted-foreground truncate"> + {user.email} + </span> + </div> + </CommandItem> + ))} + </CommandGroup> + </Command> + </PopoverContent> + </Popover> + ) +} + +// ---------------------------------------------------------------------------------------------------- + +/* EXPORT */ +export default UserComboBox; |
