diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-02 00:45:49 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-02 00:45:49 +0000 |
| commit | 2acf5f8966a40c1c9a97680c8dc263ee3f1ad3d1 (patch) | |
| tree | f406b5c86f563347c7fd088a85fd1a82284dc5ff /lib | |
| parent | 6a9ca20deddcdcbe8495cf5a73ec7ea5f53f9b55 (diff) | |
(대표님/최겸) 20250702 변경사항 업데이트
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/b-rfq/service.ts | 6 | ||||
| -rw-r--r-- | lib/b-rfq/summary-table/summary-rfq-columns.tsx | 4 | ||||
| -rw-r--r-- | lib/dashboard/dashboard-client.tsx | 115 | ||||
| -rw-r--r-- | lib/dashboard/dashboard-overview-chart.tsx | 325 | ||||
| -rw-r--r-- | lib/dashboard/dashboard-stats-card.tsx | 88 | ||||
| -rw-r--r-- | lib/dashboard/dashboard-summary-cards.tsx | 64 | ||||
| -rw-r--r-- | lib/dashboard/partners-service.ts | 447 | ||||
| -rw-r--r-- | lib/dashboard/service.ts | 454 | ||||
| -rw-r--r-- | lib/qna/service.ts | 1006 | ||||
| -rw-r--r-- | lib/qna/table/create-qna-dialog.tsx | 203 | ||||
| -rw-r--r-- | lib/qna/table/delete-qna-dialog.tsx | 250 | ||||
| -rw-r--r-- | lib/qna/table/improved-comment-section.tsx | 319 | ||||
| -rw-r--r-- | lib/qna/table/qna-detail.tsx | 455 | ||||
| -rw-r--r-- | lib/qna/table/qna-export-actions.tsx | 261 | ||||
| -rw-r--r-- | lib/qna/table/qna-table-columns.tsx | 325 | ||||
| -rw-r--r-- | lib/qna/table/qna-table-toolbar-actions.tsx | 176 | ||||
| -rw-r--r-- | lib/qna/table/qna-table.tsx | 236 | ||||
| -rw-r--r-- | lib/qna/table/update-qna-sheet.tsx | 206 | ||||
| -rw-r--r-- | lib/qna/table/utils.tsx | 329 | ||||
| -rw-r--r-- | lib/qna/validation.ts | 374 | ||||
| -rw-r--r-- | lib/users/access-control/users-table.tsx | 2 |
21 files changed, 5642 insertions, 3 deletions
diff --git a/lib/b-rfq/service.ts b/lib/b-rfq/service.ts index 8aa79084..5a65872b 100644 --- a/lib/b-rfq/service.ts +++ b/lib/b-rfq/service.ts @@ -2528,6 +2528,11 @@ export async function requestRevision( ): Promise<RequestRevisionResult> { try { // 입력값 검증 + + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + throw new Error("인증이 필요합니다.") + } const validatedData = requestRevisionSchema.parse({ responseId, revisionReason, @@ -2567,6 +2572,7 @@ export async function requestRevision( revisionRequestComment: validatedData.revisionReason, // 새로운 필드에 저장 revisionRequestedAt: new Date(), // 수정 요청 시간 저장 updatedAt: new Date(), + updatedBy: Number(session.user.id), }) .where(eq(vendorAttachmentResponses.id, validatedData.responseId)) .returning(); diff --git a/lib/b-rfq/summary-table/summary-rfq-columns.tsx b/lib/b-rfq/summary-table/summary-rfq-columns.tsx index 40f143b2..af5c22b2 100644 --- a/lib/b-rfq/summary-table/summary-rfq-columns.tsx +++ b/lib/b-rfq/summary-table/summary-rfq-columns.tsx @@ -411,11 +411,11 @@ export function getRFQColumns({ setRowAction, router }: GetRFQColumnsProps): Col <div className="flex flex-col gap-1 text-xs"> <div className="flex items-center justify-between"> <span className="text-muted-foreground">초기:</span> - <span>{initial}개사 ({initialRate}%)</span> + <span>{initial}개사 ({Number(initialRate).toFixed(0)}%)</span> </div> <div className="flex items-center justify-between"> <span className="text-muted-foreground">최종:</span> - <span>{final}개사 ({finalRate}%)</span> + <span>{final}개사 ({Number(finalRate).toFixed(0)}%)</span> </div> </div> ); diff --git a/lib/dashboard/dashboard-client.tsx b/lib/dashboard/dashboard-client.tsx new file mode 100644 index 00000000..37dc1901 --- /dev/null +++ b/lib/dashboard/dashboard-client.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { useState } from "react"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Button } from "@/components/ui/button"; +import { RefreshCw } from "lucide-react"; +import { DashboardStatsCard } from "./dashboard-stats-card"; +import { DashboardOverviewChart } from "./dashboard-overview-chart"; +import { DashboardSummaryCards } from "./dashboard-summary-cards"; +import { toast } from "sonner"; +import { DashboardData } from "./service"; + +interface DashboardClientProps { + initialData: DashboardData; + onRefresh: () => Promise<DashboardData>; +} + +export function DashboardClient({ initialData, onRefresh }: DashboardClientProps) { + const [data, setData] = useState<DashboardData>(initialData); + const [isRefreshing, setIsRefreshing] = useState(false); + + + const handleRefresh = async () => { + try { + setIsRefreshing(true); + const newData = await onRefresh(); + setData(newData); + toast.success("대시보드 데이터가 새로고침되었습니다."); + } catch (error) { + toast.error("데이터 새로고침에 실패했습니다."); + console.error("Dashboard refresh error:", error); + } finally { + setIsRefreshing(false); + } + }; + + const getDomainDisplayName = (domain: string) => { + const domainNames: Record<string, string> = { + 'procurement': '구매 관리', + 'sales': '영업 관리', + "partners": 'Partners', + 'engineering': '엔지니어링' + }; + return domainNames[domain] || domain; + }; + + return ( + <div className="space-y-6"> + {/* 헤더 */} + <div className="flex items-center justify-between"> + <div> + <h2 className="text-2xl font-bold tracking-tight"> + {getDomainDisplayName(data.domain)} Dashboard + </h2> + <p className="text-muted-foreground"> + {data.domain ==="partners"? "회사와 개인에게 할당된 일들을 보여줍니다.":"팀과 개인에게 할당된 일들을 보여줍니다."} + </p> + </div> + <Button + onClick={handleRefresh} + disabled={isRefreshing} + variant="outline" + size="sm" + > + <RefreshCw + className={`w-4 h-4 mr-2 ${isRefreshing ? 'animate-spin' : ''}`} + /> + 새로고침 + </Button> + </div> + + {/* 요약 카드 */} + <DashboardSummaryCards summary={data.summary} /> + + {/* 차트 */} + <DashboardOverviewChart + data={data.teamStats} + title={getDomainDisplayName(data.domain)} + description="업무 타입별 현황을 확인하세요" + /> + + {/* 탭 */} + <Tabs defaultValue="team" className="space-y-4"> + <TabsList className="grid w-full grid-cols-2 max-w-md"> + <TabsTrigger value="team"> {data.domain ==="partners"? "회사 업무 현황":"팀 업무 현황"}</TabsTrigger> + <TabsTrigger value="personal">내 업무 현황</TabsTrigger> + </TabsList> + + <TabsContent value="team" className="space-y-4"> + <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> + {data.teamStats.map((stats) => ( + <DashboardStatsCard + key={stats.tableName} + stats={stats} + showUserStats={false} + /> + ))} + </div> + </TabsContent> + + <TabsContent value="personal" className="space-y-4"> + <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> + {data.userStats.map((stats) => ( + <DashboardStatsCard + key={stats.tableName} + stats={stats} + showUserStats={true} + /> + ))} + </div> + </TabsContent> + </Tabs> + </div> + ); +}
\ No newline at end of file diff --git a/lib/dashboard/dashboard-overview-chart.tsx b/lib/dashboard/dashboard-overview-chart.tsx new file mode 100644 index 00000000..ca5c0006 --- /dev/null +++ b/lib/dashboard/dashboard-overview-chart.tsx @@ -0,0 +1,325 @@ +"use client"; + +import { TrendingUp, BarChart3, PieChart } from "lucide-react"; +import { Bar, BarChart, CartesianGrid, XAxis, YAxis, Pie, PieChart as RechartsPieChart, Cell, ResponsiveContainer, LabelList } from "recharts"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; +import { DashboardStats } from "@/lib/dashboard/service"; + +interface DashboardOverviewChartProps { + data: DashboardStats[]; + title: string; + description?: string; +} + +// 차트 설정 +const chartConfig = { + pending: { + label: "대기", + color: "hsl(var(--chart-1))", // 회색 계열 + }, + inProgress: { + label: "진행중", + color: "hsl(var(--chart-2))", // 파란색 계열 + }, + completed: { + label: "완료", + color: "hsl(var(--chart-3))", // 초록색 계열 + }, +} satisfies ChartConfig; + +// 파이 차트용 색상 +const PIE_COLORS = { + pending: "#6b7280", + inProgress: "#3b82f6", + completed: "#10b981" +}; + +export function DashboardOverviewChart({ data, title, description }: DashboardOverviewChartProps) { + // 바 차트용 데이터 변환 + const barChartData = data.map(item => ({ + name: item.displayName.length > 10 ? + item.displayName.substring(0, 10) + "..." : + item.displayName, + fullName: item.displayName, + pending: item.pending, + inProgress: item.inProgress, + completed: item.completed, + total: item.total + })); + + // 파이 차트용 데이터 (전체 요약) + const totalPending = data.reduce((sum, item) => sum + item.pending, 0); + const totalInProgress = data.reduce((sum, item) => sum + item.inProgress, 0); + const totalCompleted = data.reduce((sum, item) => sum + item.completed, 0); + const totalTasks = totalPending + totalInProgress + totalCompleted; + + const pieChartData = [ + { name: "대기", value: totalPending, color: PIE_COLORS.pending }, + { name: "진행중", value: totalInProgress, color: PIE_COLORS.inProgress }, + { name: "완료", value: totalCompleted, color: PIE_COLORS.completed } + ].filter(item => item.value > 0); + + // 완료율 계산 + const completionRate = totalTasks > 0 ? Math.round((totalCompleted / totalTasks) * 100) : 0; + const isImproving = completionRate > 50; // 50% 이상이면 개선으로 간주 + + return ( + <div className="grid grid-cols-1 md:grid-cols-3 gap-6"> + {/* 바 차트 - 2/3 너비 */} + <Card className="md:col-span-2"> + <CardHeader className="pb-4"> + <CardTitle className="flex items-center gap-2 text-lg"> + <BarChart3 className="h-5 w-5" /> + {title} - 업무별 현황 + </CardTitle> + {description && <CardDescription className="text-sm">{description}</CardDescription>} + </CardHeader> + <CardContent className="pb-2"> + <ChartContainer + config={chartConfig} + className="max-h-[200px] w-full" // 고정 높이로 바 차트 크기 제한 + > + <BarChart + accessibilityLayer + data={barChartData} + margin={{ top: 30, right: 15, left: 10, bottom: 5 }} // top margin 증가로 라벨 공간 확보 + > + <CartesianGrid vertical={false} /> + <XAxis + dataKey="name" + tickLine={false} + tickMargin={10} + axisLine={false} + fontSize={12} + interval={0} + // angle={-45} + textAnchor="end" + height={60} + /> + <YAxis + tickLine={false} + axisLine={false} + fontSize={12} + /> + <ChartTooltip + cursor={false} + content={<ChartTooltipContent + indicator="dashed" + labelFormatter={(label, payload) => { + const item = barChartData.find(d => d.name === label); + return item?.fullName || label; + }} + />} + /> + <Bar + dataKey="pending" + fill="var(--color-pending)" + radius={[0, 0, 4, 4]} + stackId="status" + /> + <Bar + dataKey="inProgress" + fill="var(--color-inProgress)" + radius={[0, 0, 0, 0]} + stackId="status" + /> + <Bar + dataKey="completed" + fill="var(--color-completed)" + radius={[4, 4, 0, 0]} + stackId="status" + > + <LabelList + dataKey="total" + position="top" + offset={8} + className="fill-foreground" + fontSize={12} + formatter={(value: number) => value > 0 ? value : ''} + /> + </Bar> + </BarChart> + </ChartContainer> + </CardContent> + <CardFooter className="flex-col items-start gap-1 text-sm pt-2"> + <div className="flex gap-2 items-center leading-none font-medium"> + {totalTasks > 0 && ( + <> + <TrendingUp className={`h-4 w-4 ${isImproving ? 'text-green-600' : 'text-orange-600'}`} /> + <span>완료율 {completionRate}% - {isImproving ? '순조롭게 진행중' : '진행 필요'}</span> + </> + )} + </div> + <div className="text-muted-foreground leading-none"> + 총 {totalTasks}건의 업무 현황 + </div> + </CardFooter> + </Card> + + {/* 파이 차트 - 1/3 너비 */} + <Card className="md:col-span-1"> + <CardHeader className="pb-4"> + <CardTitle className="flex items-center gap-2 text-lg"> + <PieChart className="h-5 w-5" /> + 업무 분포 + </CardTitle> + <CardDescription className="text-sm">상태별 비율</CardDescription> + </CardHeader> + <CardContent className="pb-2"> + <ChartContainer + config={chartConfig} + className="max-h-[200px] w-full" // 고정 높이로 바 차트 크기 제한 + > + <RechartsPieChart> + <ChartTooltip + cursor={false} + content={<ChartTooltipContent + hideLabel + formatter={(value, name) => [ + `${value}건 (${Math.round((Number(value) / totalTasks) * 100)}%)`, + name + ]} + />} + /> + <Pie + data={pieChartData} + cx="50%" + cy="50%" + labelLine={false} + label={({ name, percent }) => + percent > 0.1 ? `${(percent * 100).toFixed(0)}%` : '' + } + outerRadius={70} // 작은 공간에 맞게 더 작게 조정 + fill="#8884d8" + dataKey="value" + > + {pieChartData.map((entry, index) => ( + <Cell key={`cell-${index}`} fill={entry.color} /> + ))} + </Pie> + </RechartsPieChart> + </ChartContainer> + </CardContent> + <CardFooter className="pt-2"> + <div className="grid grid-cols-1 gap-1 w-full text-center"> + <div className="text-xs space-y-1"> + <div className="flex items-center justify-between"> + <span className="text-muted-foreground">대기</span> + <span className="font-medium text-gray-600">{totalPending}</span> + </div> + <div className="flex items-center justify-between"> + <span className="text-muted-foreground">진행</span> + <span className="font-medium text-blue-600">{totalInProgress}</span> + </div> + <div className="flex items-center justify-between"> + <span className="text-muted-foreground">완료</span> + <span className="font-medium text-green-600">{totalCompleted}</span> + </div> + </div> + </div> + </CardFooter> + </Card> + </div> + ); +} + +// 더 컴팩트한 버전 (높이를 더 많이 줄이고 싶은 경우) +export function CompactDashboardChart({ data, title, description }: DashboardOverviewChartProps) { + const totalPending = data.reduce((sum, item) => sum + item.pending, 0); + const totalInProgress = data.reduce((sum, item) => sum + item.inProgress, 0); + const totalCompleted = data.reduce((sum, item) => sum + item.completed, 0); + const totalTasks = totalPending + totalInProgress + totalCompleted; + + const pieChartData = [ + { name: "대기", value: totalPending, color: PIE_COLORS.pending }, + { name: "진행중", value: totalInProgress, color: PIE_COLORS.inProgress }, + { name: "완료", value: totalCompleted, color: PIE_COLORS.completed } + ].filter(item => item.value > 0); + + const completionRate = totalTasks > 0 ? Math.round((totalCompleted / totalTasks) * 100) : 0; + + return ( + <div className="grid grid-cols-1 lg:grid-cols-3 gap-4"> + {/* 요약 통계 */} + <Card className="flex items-center p-4"> + <div className="flex-1"> + <h3 className="text-sm font-medium text-muted-foreground">완료율</h3> + <div className="text-2xl font-bold">{completionRate}%</div> + <p className="text-xs text-muted-foreground">총 {totalTasks}건</p> + </div> + <TrendingUp className={`h-8 w-8 ${completionRate > 50 ? 'text-green-600' : 'text-orange-600'}`} /> + </Card> + + {/* 컴팩트 파이 차트 */} + <Card className="col-span-2"> + <CardHeader className="pb-3"> + <CardTitle className="text-lg">{title}</CardTitle> + </CardHeader> + <CardContent className="pb-3"> + <div className="flex items-center gap-6"> + {/* 작은 파이 차트 */} + <div className="flex-shrink-0"> + <ChartContainer + config={chartConfig} + className="h-[120px] w-[120px]" + > + <RechartsPieChart> + <ChartTooltip + cursor={false} + content={<ChartTooltipContent + hideLabel + formatter={(value, name) => [`${value}건`, name]} + />} + /> + <Pie + data={pieChartData} + cx="50%" + cy="50%" + outerRadius={50} + innerRadius={20} // 도넛 차트로 만들어 공간 절약 + fill="#8884d8" + dataKey="value" + > + {pieChartData.map((entry, index) => ( + <Cell key={`cell-${index}`} fill={entry.color} /> + ))} + </Pie> + </RechartsPieChart> + </ChartContainer> + </div> + + {/* 통계 목록 */} + <div className="flex-1 grid grid-cols-1 gap-3"> + <div className="flex items-center gap-3"> + <div className="w-3 h-3 rounded-full bg-gray-500"></div> + <span className="text-sm">대기: {totalPending}건</span> + </div> + <div className="flex items-center gap-3"> + <div className="w-3 h-3 rounded-full bg-blue-500"></div> + <span className="text-sm">진행중: {totalInProgress}건</span> + </div> + <div className="flex items-center gap-3"> + <div className="w-3 h-3 rounded-full bg-green-500"></div> + <span className="text-sm">완료: {totalCompleted}건</span> + </div> + </div> + </div> + </CardContent> + </Card> + </div> + ); +} + diff --git a/lib/dashboard/dashboard-stats-card.tsx b/lib/dashboard/dashboard-stats-card.tsx new file mode 100644 index 00000000..4485e8e0 --- /dev/null +++ b/lib/dashboard/dashboard-stats-card.tsx @@ -0,0 +1,88 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Progress } from "@/components/ui/progress"; +import { DashboardStats, UserDashboardStats } from "./service"; + +interface DashboardStatsCardProps { + stats: DashboardStats | UserDashboardStats; + showUserStats?: boolean; +} + +export function DashboardStatsCard({ stats, showUserStats = false }: DashboardStatsCardProps) { + const userStats = showUserStats ? stats as UserDashboardStats : null; + + const completionRate = stats.total > 0 ? Math.round((stats.completed / stats.total) * 100) : 0; + const myCompletionRate = userStats && userStats.myTotal > 0 + ? Math.round((userStats.myCompleted / userStats.myTotal) * 100) + : 0; + + return ( + <Card className="h-full"> + <CardHeader className="pb-3"> + <CardTitle className="text-lg font-medium">{stats.displayName}</CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + {/* 팀 전체 통계 */} + <div className="space-y-3"> + <div className="flex items-center justify-between"> + <span className="text-sm font-medium text-muted-foreground">팀 전체</span> + <span className="text-sm font-bold">{stats.total}건</span> + </div> + + <div className="flex gap-2"> + <Badge variant="secondary" className="text-xs"> + 대기 {stats.pending} + </Badge> + <Badge variant="default" className="text-xs bg-blue-500"> + 진행 {stats.inProgress} + </Badge> + <Badge variant="default" className="text-xs bg-green-500"> + 완료 {stats.completed} + </Badge> + </div> + + <div className="space-y-1"> + <div className="flex justify-between text-xs"> + <span>완료율</span> + <span>{completionRate}%</span> + </div> + <Progress value={completionRate} className="h-2" /> + </div> + </div> + + {/* 개인 통계 */} + {showUserStats && userStats && ( + <> + <hr className="border-muted" /> + <div className="space-y-3"> + <div className="flex items-center justify-between"> + <span className="text-sm font-medium text-muted-foreground">내 업무</span> + <span className="text-sm font-bold">{userStats.myTotal}건</span> + </div> + + <div className="flex gap-2"> + <Badge variant="outline" className="text-xs"> + 대기 {userStats.myPending} + </Badge> + <Badge variant="outline" className="text-xs border-blue-500 text-blue-600"> + 진행 {userStats.myInProgress} + </Badge> + <Badge variant="outline" className="text-xs border-green-500 text-green-600"> + 완료 {userStats.myCompleted} + </Badge> + </div> + + <div className="space-y-1"> + <div className="flex justify-between text-xs"> + <span>완료율</span> + <span>{myCompletionRate}%</span> + </div> + <Progress value={myCompletionRate} className="h-2" /> + </div> + </div> + </> + )} + </CardContent> + </Card> + ); +}
\ No newline at end of file diff --git a/lib/dashboard/dashboard-summary-cards.tsx b/lib/dashboard/dashboard-summary-cards.tsx new file mode 100644 index 00000000..9d1d9ef2 --- /dev/null +++ b/lib/dashboard/dashboard-summary-cards.tsx @@ -0,0 +1,64 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { CheckCircle, Clock, PlayCircle, Users } from "lucide-react"; +import { DashboardData } from "./service"; + +interface DashboardSummaryCardsProps { + summary: DashboardData['summary']; +} + +export function DashboardSummaryCards({ summary }: DashboardSummaryCardsProps) { + const cards = [ + { + title: "전체 업무", + value: summary.totalTasks, + icon: Users, + description: `내 업무 ${summary.myTasks}건`, + color: "text-blue-600" + }, + { + title: "대기중", + value: summary.teamPending, + icon: Clock, + description: `내 대기 ${summary.myPending}건`, + color: "text-gray-600" + }, + { + title: "진행중", + value: summary.teamInProgress, + icon: PlayCircle, + description: `내 진행 ${summary.myInProgress}건`, + color: "text-blue-600" + }, + { + title: "완료", + value: summary.teamCompleted, + icon: CheckCircle, + description: `내 완료 ${summary.myCompleted}건`, + color: "text-green-600" + } + ]; + + return ( + <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> + {cards.map((card, index) => { + const Icon = card.icon; + return ( + <Card key={index}> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium"> + {card.title} + </CardTitle> + <Icon className={`h-4 w-4 ${card.color}`} /> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold">{card.value}</div> + <p className="text-xs text-muted-foreground"> + {card.description} + </p> + </CardContent> + </Card> + ); + })} + </div> + ); +}
\ No newline at end of file diff --git a/lib/dashboard/partners-service.ts b/lib/dashboard/partners-service.ts new file mode 100644 index 00000000..327a16a9 --- /dev/null +++ b/lib/dashboard/partners-service.ts @@ -0,0 +1,447 @@ +"use server"; + +import db from "@/db/db"; +import { sql } from "drizzle-orm"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import { getPartnerTablesByDomain } from "@/config/partners-dashboard-table"; +import { TableConfig } from "@/types/dashboard"; + +export interface PartnersDashboardStats { + tableName: string; + displayName: string; + total: number; + pending: number; + inProgress: number; + completed: number; +} + +export interface PartnersUserDashboardStats extends PartnersDashboardStats { + myTotal: number; + myPending: number; + myInProgress: number; + myCompleted: number; +} + +export interface PartnersDashboardData { + domain: string; + companyId: string; + teamStats: PartnersDashboardStats[]; + userStats: PartnersUserDashboardStats[]; + summary: { + totalTasks: number; + myTasks: number; + teamPending: number; + teamInProgress: number; + teamCompleted: number; + myPending: number; + myInProgress: number; + myCompleted: number; + }; +} + +// Partners 팀 대시보드 데이터 조회 (회사 필터링 포함) +export async function getPartnersTeamDashboardData(domain: string): Promise<PartnersDashboardStats[]> { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.companyId) { + throw new Error("회사 정보가 없습니다."); + } + + const companyId = session.user.companyId; + const tables = getPartnerTablesByDomain(domain); + + if (tables.length === 0) { + console.warn(`파트너 도메인 '${domain}'에 대한 테이블이 없습니다.`); + return []; + } + + console.log(`👥 회사 ID: ${companyId}로 파트너 데이터 조회`); + + // 병렬 처리로 성능 향상 + const results = await Promise.allSettled( + tables.map(tableConfig => getPartnersTableStats(tableConfig, companyId)) + ); + + // 성공한 결과만 반환 + const successfulResults: PartnersDashboardStats[] = []; + results.forEach((result, index) => { + if (result.status === 'fulfilled') { + successfulResults.push(result.value); + } else { + console.error(`파트너 테이블 ${tables[index].tableName} 통계 조회 실패:`, result.reason); + } + }); + + console.log('📊 파트너 팀 대시보드 결과:', successfulResults); + return successfulResults; + } catch (error) { + console.error("파트너 팀 대시보드 데이터 조회 실패:", error); + throw new Error("파트너 팀 대시보드 데이터를 불러오는데 실패했습니다."); + } +} + +// Partners 사용자 대시보드 데이터 조회 +export async function getPartnersUserDashboardData(domain: string): Promise<PartnersUserDashboardStats[]> { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id || !session?.user?.companyId) { + throw new Error("사용자 또는 회사 정보가 없습니다."); + } + + const userId = session.user.id; + const companyId = session.user.companyId; + const tables = getPartnerTablesByDomain(domain); + + if (tables.length === 0) { + console.warn(`파트너 도메인 '${domain}'에 대한 테이블이 없습니다.`); + return []; + } + + console.log(`👤 사용자 ID: ${userId}, 회사 ID: ${companyId}`); + + // 병렬 처리로 성능 향상 + const results = await Promise.allSettled( + tables.map(async (tableConfig) => { + const [teamStats, userStats] = await Promise.all([ + getPartnersTableStats(tableConfig, companyId), + getPartnersUserTableStats(tableConfig, companyId, userId) + ]); + + return { + ...teamStats, + myTotal: userStats.total, + myPending: userStats.pending, + myInProgress: userStats.inProgress, + myCompleted: userStats.completed + } as PartnersUserDashboardStats; + }) + ); + + // 성공한 결과만 반환 + const successfulResults: PartnersUserDashboardStats[] = []; + results.forEach((result, index) => { + if (result.status === 'fulfilled') { + successfulResults.push(result.value); + } else { + console.error(`파트너 테이블 ${tables[index].tableName} 사용자 통계 조회 실패:`, result.reason); + } + }); + + return successfulResults; + } catch (error) { + console.error("파트너 사용자 대시보드 데이터 조회 실패:", error); + throw new Error("파트너 사용자 대시보드 데이터를 불러오는데 실패했습니다."); + } +} + +// Partners 전체 대시보드 데이터 조회 +export async function getPartnersDashboardData(domain: string): Promise<PartnersDashboardData> { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id || !session?.user?.companyId) { + throw new Error("사용자 또는 회사 정보가 없습니다."); + } + + const [teamStats, userStats] = await Promise.all([ + getPartnersTeamDashboardData(domain), + getPartnersUserDashboardData(domain) + ]); + + // 요약 통계 계산 + const summary = { + totalTasks: teamStats.reduce((sum, stat) => sum + stat.total, 0), + myTasks: userStats.reduce((sum, stat) => sum + stat.myTotal, 0), + teamPending: teamStats.reduce((sum, stat) => sum + stat.pending, 0), + teamInProgress: teamStats.reduce((sum, stat) => sum + stat.inProgress, 0), + teamCompleted: teamStats.reduce((sum, stat) => sum + stat.completed, 0), + myPending: userStats.reduce((sum, stat) => sum + stat.myPending, 0), + myInProgress: userStats.reduce((sum, stat) => sum + stat.myInProgress, 0), + myCompleted: userStats.reduce((sum, stat) => sum + stat.myCompleted, 0) + }; + + return { + domain, + companyId: session.user.companyId, + teamStats, + userStats, + summary + }; + } catch (error) { + console.error("파트너 대시보드 데이터 조회 실패:", error); + throw new Error("파트너 대시보드 데이터를 불러오는데 실패했습니다."); + } +} + +// Partners 테이블별 전체 통계 조회 (회사 필터링 포함) +async function getPartnersTableStats(config: TableConfig, companyId: string): Promise<PartnersDashboardStats> { + try { + console.log(`\n🔍 파트너 테이블 ${config.tableName} 통계 조회 (회사: ${companyId})`); + + // 1단계: 회사별 총 개수 확인 + const totalQuery = ` + SELECT COUNT(*)::INTEGER as total + FROM "${config.tableName}" + WHERE "vendor_id" = '${companyId}' + `; + console.log("Total SQL:", totalQuery); + + const totalResult = await db.execute(sql.raw(totalQuery)); + console.log("Total 결과:", totalResult.rows[0]); + + // 2단계: 회사별 상태값 분포 확인 + const statusQuery = ` + SELECT "${config.statusField}" as status, COUNT(*) as count + FROM "${config.tableName}" + WHERE "vendor_id" = '${companyId}' AND "${config.statusField}" IS NOT NULL + GROUP BY "${config.statusField}" + ORDER BY count DESC + `; + console.log("Status SQL:", statusQuery); + + const statusResult = await db.execute(sql.raw(statusQuery)); + console.log("Status 결과:", statusResult.rows); + + // 3단계: 상태별 개수 조회 + const pendingValues = Object.entries(config.statusMapping) + .filter(([_, mapped]) => mapped === 'pending') + .map(([original]) => original); + + const inProgressValues = Object.entries(config.statusMapping) + .filter(([_, mapped]) => mapped === 'in_progress') + .map(([original]) => original); + + const completedValues = Object.entries(config.statusMapping) + .filter(([_, mapped]) => mapped === 'completed') + .map(([original]) => original); + + console.log("파트너 상태 매핑:"); + console.log("- pending:", pendingValues); + console.log("- inProgress:", inProgressValues); + console.log("- completed:", completedValues); + + let pendingCount = 0; + let inProgressCount = 0; + let completedCount = 0; + + // Pending 개수 (회사 필터 포함) + if (pendingValues.length > 0) { + const pendingValuesList = pendingValues.map(v => `'${v.replace(/'/g, "''")}'`).join(','); + const pendingQuery = ` + SELECT COUNT(*)::INTEGER as count + FROM "${config.tableName}" + WHERE "vendor_id" = '${companyId}' AND "${config.statusField}" IN (${pendingValuesList}) + `; + + const pendingResult = await db.execute(sql.raw(pendingQuery)); + pendingCount = parseInt(pendingResult.rows[0]?.count || '0'); + console.log("Pending 개수:", pendingCount); + } + + // In Progress 개수 (회사 필터 포함) + if (inProgressValues.length > 0) { + const inProgressValuesList = inProgressValues.map(v => `'${v.replace(/'/g, "''")}'`).join(','); + const inProgressQuery = ` + SELECT COUNT(*)::INTEGER as count + FROM "${config.tableName}" + WHERE "vendor_id" = '${companyId}' AND "${config.statusField}" IN (${inProgressValuesList}) + `; + + const inProgressResult = await db.execute(sql.raw(inProgressQuery)); + inProgressCount = parseInt(inProgressResult.rows[0]?.count || '0'); + console.log("InProgress 개수:", inProgressCount); + } + + // Completed 개수 (회사 필터 포함) + if (completedValues.length > 0) { + const completedValuesList = completedValues.map(v => `'${v.replace(/'/g, "''")}'`).join(','); + const completedQuery = ` + SELECT COUNT(*)::INTEGER as count + FROM "${config.tableName}" + WHERE "vendor_id" = '${companyId}' AND "${config.statusField}" IN (${completedValuesList}) + `; + + const completedResult = await db.execute(sql.raw(completedQuery)); + completedCount = parseInt(completedResult.rows[0]?.count || '0'); + console.log("Completed 개수:", completedCount); + } + + const stats = { + tableName: config.tableName, + displayName: config.displayName, + total: parseInt(totalResult.rows[0]?.total || '0'), + pending: pendingCount, + inProgress: inProgressCount, + completed: completedCount + }; + + console.log(`✅ 파트너 ${config.tableName} 최종 통계:`, stats); + return stats; + } catch (error) { + console.error(`❌ 파트너 테이블 ${config.tableName} 통계 조회 중 오류:`, error); + return createEmptyPartnersStats(config); + } +} + +// Partners 사용자별 테이블 통계 조회 (회사 + 사용자 필터링) +async function getPartnersUserTableStats(config: TableConfig, companyId: string, userId: string): Promise<PartnersDashboardStats> { + try { + // 사용자 필드가 없는 경우 빈 통계 반환 + if (!hasUserFields(config)) { + console.log(`⚠️ 파트너 테이블 ${config.tableName}에 사용자 필드가 없습니다.`); + return createEmptyPartnersStats(config); + } + + console.log(`\n👤 파트너 사용자 ${userId}의 ${config.tableName} 통계 조회 (회사: ${companyId})`); + + // 사용자 조건 생성 (회사 필터 포함) + const userConditions = []; + if (config.userFields.creator) { + userConditions.push(`"${config.userFields.creator}" = '${userId}'`); + } + if (config.userFields.updater) { + userConditions.push(`"${config.userFields.updater}" = '${userId}'`); + } + if (config.userFields.assignee) { + userConditions.push(`"${config.userFields.assignee}" = '${userId}'`); + } + + if (userConditions.length === 0) { + return createEmptyPartnersStats(config); + } + + const userConditionStr = userConditions.join(' OR '); + + // 1. 사용자 + 회사 총 개수 + const userTotalQuery = ` + SELECT COUNT(*)::INTEGER as total + FROM "${config.tableName}" + WHERE "vendor_id" = '${companyId}' AND (${userConditionStr}) + `; + console.log("User Total SQL:", userTotalQuery); + + const userTotalResult = await db.execute(sql.raw(userTotalQuery)); + console.log("User Total 결과:", userTotalResult.rows[0]); + + // 2. 사용자 + 회사 상태별 개수 + const pendingValues = Object.entries(config.statusMapping) + .filter(([_, mapped]) => mapped === 'pending') + .map(([original]) => original); + + const inProgressValues = Object.entries(config.statusMapping) + .filter(([_, mapped]) => mapped === 'in_progress') + .map(([original]) => original); + + const completedValues = Object.entries(config.statusMapping) + .filter(([_, mapped]) => mapped === 'completed') + .map(([original]) => original); + + let userPendingCount = 0; + let userInProgressCount = 0; + let userCompletedCount = 0; + + // User + Company Pending 개수 + if (pendingValues.length > 0) { + const pendingValuesList = pendingValues.map(v => `'${v.replace(/'/g, "''")}'`).join(','); + const userPendingQuery = ` + SELECT COUNT(*)::INTEGER as count + FROM "${config.tableName}" + WHERE "vendor_id" = '${companyId}' AND (${userConditionStr}) AND "${config.statusField}" IN (${pendingValuesList}) + `; + + const userPendingResult = await db.execute(sql.raw(userPendingQuery)); + userPendingCount = parseInt(userPendingResult.rows[0]?.count || '0'); + console.log("User Pending 개수:", userPendingCount); + } + + // User + Company In Progress 개수 + if (inProgressValues.length > 0) { + const inProgressValuesList = inProgressValues.map(v => `'${v.replace(/'/g, "''")}'`).join(','); + const userInProgressQuery = ` + SELECT COUNT(*)::INTEGER as count + FROM "${config.tableName}" + WHERE "vendor_id" = '${companyId}' AND (${userConditionStr}) AND "${config.statusField}" IN (${inProgressValuesList}) + `; + + const userInProgressResult = await db.execute(sql.raw(userInProgressQuery)); + userInProgressCount = parseInt(userInProgressResult.rows[0]?.count || '0'); + console.log("User InProgress 개수:", userInProgressCount); + } + + // User + Company Completed 개수 + if (completedValues.length > 0) { + const completedValuesList = completedValues.map(v => `'${v.replace(/'/g, "''")}'`).join(','); + const userCompletedQuery = ` + SELECT COUNT(*)::INTEGER as count + FROM "${config.tableName}" + WHERE "vendor_id" = '${companyId}' AND (${userConditionStr}) AND "${config.statusField}" IN (${completedValuesList}) + `; + + const userCompletedResult = await db.execute(sql.raw(userCompletedQuery)); + userCompletedCount = parseInt(userCompletedResult.rows[0]?.count || '0'); + console.log("User Completed 개수:", userCompletedCount); + } + + const stats = { + tableName: config.tableName, + displayName: config.displayName, + total: parseInt(userTotalResult.rows[0]?.total || '0'), + pending: userPendingCount, + inProgress: userInProgressCount, + completed: userCompletedCount + }; + + console.log(`✅ 파트너 사용자 ${config.tableName} 최종 통계:`, stats); + return stats; + } catch (error) { + console.error(`❌ 파트너 테이블 ${config.tableName} 사용자 통계 조회 중 오류:`, error); + return createEmptyPartnersStats(config); + } +} + +// 유틸리티 함수들 +function createEmptyPartnersStats(config: TableConfig): PartnersDashboardStats { + return { + tableName: config.tableName, + displayName: config.displayName, + total: 0, + pending: 0, + inProgress: 0, + completed: 0 + }; +} + +function hasUserFields(config: TableConfig): boolean { + return !!(config.userFields.creator || config.userFields.updater || config.userFields.assignee); +} + +// 디버깅 함수: Partners 전용 +export async function simplePartnersTest(tableName: string, statusField: string, companyId: string) { + try { + console.log(`\n🧪 파트너 ${tableName} 간단한 테스트 (회사: ${companyId}):`); + + // 1. 회사별 총 개수 + const totalQuery = `SELECT COUNT(*) as total FROM "${tableName}" WHERE "vendor_id" = '${companyId}'`; + const totalResult = await db.execute(sql.raw(totalQuery)); + console.log("회사별 총 개수:", totalResult.rows[0]); + + // 2. 회사별 상태 분포 + const statusQuery = ` + SELECT "${statusField}" as status, COUNT(*) as count + FROM "${tableName}" + WHERE "vendor_id" = '${companyId}' + GROUP BY "${statusField}" + ORDER BY count DESC + `; + const statusResult = await db.execute(sql.raw(statusQuery)); + console.log("회사별 상태 분포:", statusResult.rows); + + return { + total: totalResult.rows[0], + statusDistribution: statusResult.rows + }; + } catch (error) { + console.error("파트너 간단한 테스트 실패:", error); + return null; + } +}
\ No newline at end of file diff --git a/lib/dashboard/service.ts b/lib/dashboard/service.ts new file mode 100644 index 00000000..16b05d45 --- /dev/null +++ b/lib/dashboard/service.ts @@ -0,0 +1,454 @@ +"use server"; + +import db from "@/db/db"; +import { sql, eq, or, and, count, sum, inArray } from "drizzle-orm"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import { getTablesByDomain } from "@/config/dashboard-table"; +import { TableConfig } from "@/types/dashboard"; + +export interface DashboardStats { + tableName: string; + displayName: string; + total: number; + pending: number; + inProgress: number; + completed: number; +} + +export interface UserDashboardStats extends DashboardStats { + myTotal: number; + myPending: number; + myInProgress: number; + myCompleted: number; +} + +export interface DashboardData { + domain: string; + teamStats: DashboardStats[]; + userStats: UserDashboardStats[]; + summary: { + totalTasks: number; + myTasks: number; + teamPending: number; + teamInProgress: number; + teamCompleted: number; + myPending: number; + myInProgress: number; + myCompleted: number; + }; +} + +// 팀 대시보드 데이터 조회 +export async function getTeamDashboardData(domain: string): Promise<DashboardStats[]> { + try { + const tables = getTablesByDomain(domain); + + if (tables.length === 0) { + console.warn(`도메인 '${domain}'에 대한 테이블이 없습니다.`); + return []; + } + + // 병렬 처리로 성능 향상 + const results = await Promise.allSettled( + tables.map(tableConfig => getTableStats(tableConfig)) + ); + + // 성공한 결과만 반환, 실패한 것들은 로그 출력 + const successfulResults: DashboardStats[] = []; + results.forEach((result, index) => { + if (result.status === 'fulfilled') { + successfulResults.push(result.value); + } else { + console.error(`테이블 ${tables[index].tableName} 통계 조회 실패:`, result.reason); + } + }); + + console.log('📊 팀 대시보드 결과:', successfulResults); + + return successfulResults; + } catch (error) { + console.error("팀 대시보드 데이터 조회 실패:", error); + throw new Error("팀 대시보드 데이터를 불러오는데 실패했습니다."); + } +} + +// 사용자 대시보드 데이터 조회 +export async function getUserDashboardData(domain: string): Promise<UserDashboardStats[]> { + try { + // 현재 사용자 정보 가져오기 + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + throw new Error("인증되지 않은 사용자입니다."); + } + + const userId = session.user.id; + const tables = getTablesByDomain(domain); + + if (tables.length === 0) { + console.warn(`도메인 '${domain}'에 대한 테이블이 없습니다.`); + return []; + } + + console.log(`👤 사용자 ID: ${userId}`); + + // 병렬 처리로 성능 향상 + const results = await Promise.allSettled( + tables.map(async (tableConfig) => { + const [teamStats, userStats] = await Promise.all([ + getTableStats(tableConfig), + getUserTableStats(tableConfig, userId) + ]); + + return { + ...teamStats, + myTotal: userStats.total, + myPending: userStats.pending, + myInProgress: userStats.inProgress, + myCompleted: userStats.completed + } as UserDashboardStats; + }) + ); + + // 성공한 결과만 반환 + const successfulResults: UserDashboardStats[] = []; + results.forEach((result, index) => { + if (result.status === 'fulfilled') { + successfulResults.push(result.value); + } else { + console.error(`테이블 ${tables[index].tableName} 사용자 통계 조회 실패:`, result.reason); + } + }); + + return successfulResults; + } catch (error) { + console.error("사용자 대시보드 데이터 조회 실패:", error); + throw new Error("사용자 대시보드 데이터를 불러오는데 실패했습니다."); + } +} + +// 전체 대시보드 데이터 조회 (팀 + 개인) +export async function getDashboardData(domain: string): Promise<DashboardData> { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + throw new Error("인증되지 않은 사용자입니다."); + } + + // 병렬 처리로 성능 향상 + const [teamStats, userStats] = await Promise.all([ + getTeamDashboardData(domain), + getUserDashboardData(domain) + ]); + + // 요약 통계 계산 + const summary = { + totalTasks: teamStats.reduce((sum, stat) => sum + stat.total, 0), + myTasks: userStats.reduce((sum, stat) => sum + stat.myTotal, 0), + teamPending: teamStats.reduce((sum, stat) => sum + stat.pending, 0), + teamInProgress: teamStats.reduce((sum, stat) => sum + stat.inProgress, 0), + teamCompleted: teamStats.reduce((sum, stat) => sum + stat.completed, 0), + myPending: userStats.reduce((sum, stat) => sum + stat.myPending, 0), + myInProgress: userStats.reduce((sum, stat) => sum + stat.myInProgress, 0), + myCompleted: userStats.reduce((sum, stat) => sum + stat.myCompleted, 0) + }; + + return { + domain, + teamStats, + userStats, + summary + }; + } catch (error) { + console.error("대시보드 데이터 조회 실패:", error); + throw new Error("대시보드 데이터를 불러오는데 실패했습니다."); + } +} + +// 테이블별 전체 통계 조회 (완전히 수정된 버전) +async function getTableStats(config: TableConfig): Promise<DashboardStats> { + try { + console.log(`\n🔍 테이블 ${config.tableName} 통계 조회 시작`); + + // 1단계: 기본 총 개수 확인 + console.log("1단계: 총 개수 조회"); + const totalQuery = `SELECT COUNT(*)::INTEGER as total FROM "${config.tableName}"`; + console.log("Total SQL:", totalQuery); + + const totalResult = await db.execute(sql.raw(totalQuery)); + console.log("Total 결과:", totalResult.rows[0]); + + // 2단계: 실제 상태값 확인 + console.log("2단계: 상태값 분포 확인"); + const statusQuery = ` + SELECT "${config.statusField}" as status, COUNT(*) as count + FROM "${config.tableName}" + WHERE "${config.statusField}" IS NOT NULL + GROUP BY "${config.statusField}" + ORDER BY count DESC + `; + console.log("Status SQL:", statusQuery); + + const statusResult = await db.execute(sql.raw(statusQuery)); + console.log("Status 결과:", statusResult.rows); + + // 3단계: 상태별 개수 조회 (개별 쿼리) + console.log("3단계: 상태별 개수 조회"); + + const pendingValues = Object.entries(config.statusMapping) + .filter(([_, mapped]) => mapped === 'pending') + .map(([original]) => original); + + const inProgressValues = Object.entries(config.statusMapping) + .filter(([_, mapped]) => mapped === 'in_progress') + .map(([original]) => original); + + const completedValues = Object.entries(config.statusMapping) + .filter(([_, mapped]) => mapped === 'completed') + .map(([original]) => original); + + console.log("매핑된 상태값:"); + console.log("- pending:", pendingValues); + console.log("- inProgress:", inProgressValues); + console.log("- completed:", completedValues); + + // 개별 쿼리로 정확한 개수 조회 + let pendingCount = 0; + let inProgressCount = 0; + let completedCount = 0; + + // Pending 개수 + if (pendingValues.length > 0) { + const pendingValuesList = pendingValues.map(v => `'${v.replace(/'/g, "''")}'`).join(','); + const pendingQuery = ` + SELECT COUNT(*)::INTEGER as count + FROM "${config.tableName}" + WHERE "${config.statusField}" IN (${pendingValuesList}) + `; + console.log("Pending SQL:", pendingQuery); + + const pendingResult = await db.execute(sql.raw(pendingQuery)); + pendingCount = parseInt(pendingResult.rows[0]?.count || '0'); + console.log("Pending 개수:", pendingCount); + } + + // In Progress 개수 + if (inProgressValues.length > 0) { + const inProgressValuesList = inProgressValues.map(v => `'${v.replace(/'/g, "''")}'`).join(','); + const inProgressQuery = ` + SELECT COUNT(*)::INTEGER as count + FROM "${config.tableName}" + WHERE "${config.statusField}" IN (${inProgressValuesList}) + `; + console.log("InProgress SQL:", inProgressQuery); + + const inProgressResult = await db.execute(sql.raw(inProgressQuery)); + inProgressCount = parseInt(inProgressResult.rows[0]?.count || '0'); + console.log("InProgress 개수:", inProgressCount); + } + + // Completed 개수 + if (completedValues.length > 0) { + const completedValuesList = completedValues.map(v => `'${v.replace(/'/g, "''")}'`).join(','); + const completedQuery = ` + SELECT COUNT(*)::INTEGER as count + FROM "${config.tableName}" + WHERE "${config.statusField}" IN (${completedValuesList}) + `; + console.log("Completed SQL:", completedQuery); + + const completedResult = await db.execute(sql.raw(completedQuery)); + completedCount = parseInt(completedResult.rows[0]?.count || '0'); + console.log("Completed 개수:", completedCount); + } + + const stats = { + tableName: config.tableName, + displayName: config.displayName, + total: parseInt(totalResult.rows[0]?.total || '0'), + pending: pendingCount, + inProgress: inProgressCount, + completed: completedCount + }; + + console.log(`✅ ${config.tableName} 최종 통계:`, stats); + return stats; + } catch (error) { + console.error(`❌ 테이블 ${config.tableName} 통계 조회 중 오류:`, error); + // 에러 발생 시 빈 통계 반환 + return createEmptyStats(config); + } +} + +// 사용자별 테이블 통계 조회 (수정된 버전) +async function getUserTableStats(config: TableConfig, userId: string): Promise<DashboardStats> { + try { + // 사용자 필드가 없는 경우 빈 통계 반환 + if (!hasUserFields(config)) { + console.log(`⚠️ 테이블 ${config.tableName}에 사용자 필드가 없습니다.`); + return createEmptyStats(config); + } + + console.log(`\n👤 사용자 ${userId}의 ${config.tableName} 통계 조회`); + + // 사용자 조건 생성 + const userConditions = []; + if (config.userFields.creator) { + userConditions.push(`"${config.userFields.creator}" = '${userId}'`); + } + if (config.userFields.updater) { + userConditions.push(`"${config.userFields.updater}" = '${userId}'`); + } + if (config.userFields.assignee) { + userConditions.push(`"${config.userFields.assignee}" = '${userId}'`); + } + + if (userConditions.length === 0) { + return createEmptyStats(config); + } + + const userConditionStr = userConditions.join(' OR '); + + // 1. 사용자 총 개수 + const userTotalQuery = ` + SELECT COUNT(*)::INTEGER as total + FROM "${config.tableName}" + WHERE ${userConditionStr} + `; + console.log("User Total SQL:", userTotalQuery); + + const userTotalResult = await db.execute(sql.raw(userTotalQuery)); + console.log("User Total 결과:", userTotalResult.rows[0]); + + // 2. 사용자 상태별 개수 + const pendingValues = Object.entries(config.statusMapping) + .filter(([_, mapped]) => mapped === 'pending') + .map(([original]) => original); + + const inProgressValues = Object.entries(config.statusMapping) + .filter(([_, mapped]) => mapped === 'in_progress') + .map(([original]) => original); + + const completedValues = Object.entries(config.statusMapping) + .filter(([_, mapped]) => mapped === 'completed') + .map(([original]) => original); + + let userPendingCount = 0; + let userInProgressCount = 0; + let userCompletedCount = 0; + + // User Pending 개수 + if (pendingValues.length > 0) { + const pendingValuesList = pendingValues.map(v => `'${v.replace(/'/g, "''")}'`).join(','); + const userPendingQuery = ` + SELECT COUNT(*)::INTEGER as count + FROM "${config.tableName}" + WHERE (${userConditionStr}) AND "${config.statusField}" IN (${pendingValuesList}) + `; + + const userPendingResult = await db.execute(sql.raw(userPendingQuery)); + userPendingCount = parseInt(userPendingResult.rows[0]?.count || '0'); + console.log("User Pending 개수:", userPendingCount); + } + + // User In Progress 개수 + if (inProgressValues.length > 0) { + const inProgressValuesList = inProgressValues.map(v => `'${v.replace(/'/g, "''")}'`).join(','); + const userInProgressQuery = ` + SELECT COUNT(*)::INTEGER as count + FROM "${config.tableName}" + WHERE (${userConditionStr}) AND "${config.statusField}" IN (${inProgressValuesList}) + `; + + const userInProgressResult = await db.execute(sql.raw(userInProgressQuery)); + userInProgressCount = parseInt(userInProgressResult.rows[0]?.count || '0'); + console.log("User InProgress 개수:", userInProgressCount); + } + + // User Completed 개수 + if (completedValues.length > 0) { + const completedValuesList = completedValues.map(v => `'${v.replace(/'/g, "''")}'`).join(','); + const userCompletedQuery = ` + SELECT COUNT(*)::INTEGER as count + FROM "${config.tableName}" + WHERE (${userConditionStr}) AND "${config.statusField}" IN (${completedValuesList}) + `; + + const userCompletedResult = await db.execute(sql.raw(userCompletedQuery)); + userCompletedCount = parseInt(userCompletedResult.rows[0]?.count || '0'); + console.log("User Completed 개수:", userCompletedCount); + } + + const stats = { + tableName: config.tableName, + displayName: config.displayName, + total: parseInt(userTotalResult.rows[0]?.total || '0'), + pending: userPendingCount, + inProgress: userInProgressCount, + completed: userCompletedCount + }; + + console.log(`✅ 사용자 ${config.tableName} 최종 통계:`, stats); + return stats; + } catch (error) { + console.error(`❌ 테이블 ${config.tableName} 사용자 통계 조회 중 오류:`, error); + return createEmptyStats(config); + } +} + +// 유틸리티 함수들 +function createEmptyStats(config: TableConfig): DashboardStats { + return { + tableName: config.tableName, + displayName: config.displayName, + total: 0, + pending: 0, + inProgress: 0, + completed: 0 + }; +} + +function hasUserFields(config: TableConfig): boolean { + return !!(config.userFields.creator || config.userFields.updater || config.userFields.assignee); +} + +// 디버깅 함수: 단순한 테스트 +export async function simpleTest(tableName: string, statusField: string) { + try { + console.log(`\n🧪 ${tableName} 간단한 테스트:`); + + // 1. 총 개수 + const totalQuery = `SELECT COUNT(*) as total FROM "${tableName}"`; + const totalResult = await db.execute(sql.raw(totalQuery)); + console.log("총 개수:", totalResult.rows[0]); + + // 2. 상태 분포 + const statusQuery = ` + SELECT "${statusField}" as status, COUNT(*) as count + FROM "${tableName}" + GROUP BY "${statusField}" + ORDER BY count DESC + `; + const statusResult = await db.execute(sql.raw(statusQuery)); + console.log("상태 분포:", statusResult.rows); + + // 3. 특정 상태 테스트 + const draftQuery = `SELECT COUNT(*) as count FROM "${tableName}" WHERE "${statusField}" = 'DRAFT'`; + const draftResult = await db.execute(sql.raw(draftQuery)); + console.log("DRAFT 개수:", draftResult.rows[0]); + + const docConfirmedQuery = `SELECT COUNT(*) as count FROM "${tableName}" WHERE "${statusField}" = 'Doc. Confirmed'`; + const docConfirmedResult = await db.execute(sql.raw(docConfirmedQuery)); + console.log("Doc. Confirmed 개수:", docConfirmedResult.rows[0]); + + return { + total: totalResult.rows[0], + statusDistribution: statusResult.rows, + draft: draftResult.rows[0], + docConfirmed: docConfirmedResult.rows[0] + }; + } catch (error) { + console.error("간단한 테스트 실패:", error); + return null; + } +}
\ No newline at end of file diff --git a/lib/qna/service.ts b/lib/qna/service.ts new file mode 100644 index 00000000..d9c877c6 --- /dev/null +++ b/lib/qna/service.ts @@ -0,0 +1,1006 @@ +"use server"; + +import { revalidateTag, unstable_noStore } from "next/cache"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import db from "@/db/db"; +import { qna, qnaAnswer, qnaComments } from "@/db/schema/qna"; +import { qnaView, qnaAnswerView, qnaCommentView } from "@/db/schema"; +import { + eq, + desc, + asc, + and, + or, + ilike, + inArray, + gte, + lte, + isNull, + isNotNull, + count, + sql +} from "drizzle-orm"; +import { unstable_cache } from "@/lib/unstable-cache"; +import { filterColumns } from "@/lib/filter-columns"; +import type { + GetQnaSchema, + CreateQnaSchema, + UpdateQnaSchema, + CreateAnswerSchema, + UpdateAnswerSchema, + CreateCommentSchema, + UpdateCommentSchema, +} from "./validation"; + +/* ================================================================ + Helper Functions +================================================================ */ + +/** + * 인증된 사용자 정보 가져오기 + */ +async function getAuthenticatedUser() { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + throw new Error("인증이 필요합니다."); + } + return parseInt(session.user.id); +} + +/** + * 현재 사용자 ID 가져오기 (비로그인 허용) + */ +async function getCurrentUserId() { + try { + const session = await getServerSession(authOptions); + return session?.user?.id ? parseInt(session.user.id) : null; + } catch { + return null; + } +} + +/* ================================================================ + Repository Functions (트랜잭션 지원) +================================================================ */ + +/** + * Q&A 목록 조회 Repository + */ +async function selectQnaList( + tx: any, + options: { + where?: any; + orderBy?: any[]; + offset: number; + limit: number; + } +) { + const { where, orderBy = [desc(qnaView.createdAt)], offset, limit } = options; + + return await tx + .select() + .from(qnaView) + .where(where) + .orderBy(...orderBy) + .offset(offset) + .limit(limit); +} + +/** + * Q&A 총 개수 조회 Repository + */ +async function countQnaList(tx: any, where?: any) { + const [result] = await tx + .select({ count: count() }) + .from(qnaView) + .where(where); + return result.count; +} + +/** + * Q&A 상세 조회 Repository + */ +async function selectQnaDetail(tx: any, id: number) { + const [question] = await tx + .select() + .from(qnaView) + .where(eq(qnaView.id, id)); + + return question || null; +} + +/** + * 답변 목록 조회 Repository + */ +async function selectAnswersByQnaId(tx: any, qnaId: number) { + return await tx + .select() + .from(qnaAnswerView) + .where(eq(qnaAnswerView.qnaId, qnaId)) + .orderBy(desc(qnaAnswerView.createdAt)); +} + +/** + * 댓글 목록 조회 Repository (계층형) + */ +async function selectCommentsByAnswerId(tx: any, answerId: number) { + return await tx + .select() + .from(qnaCommentView) + .where(eq(qnaCommentView.answerId, answerId)) + .orderBy(asc(qnaCommentView.createdAt)); // 댓글은 오래된 순으로 +} + +/** + * 내가 답변한 질문 ID 목록 조회 + */ +async function getMyAnsweredQnaIds(tx: any, userId: number, qnaIds: number[]) { + if (qnaIds.length === 0) return []; + + const results = await tx + .select({ qnaId: qnaAnswer.qnaId }) + .from(qnaAnswer) + .where( + and( + eq(qnaAnswer.author, userId), + inArray(qnaAnswer.qnaId, qnaIds), + eq(qnaAnswer.isDeleted, false) + ) + ); + + return results.map(r => r.qnaId); +} + +/* ================================================================ + 1) Q&A 목록 조회 (고급 필터링/정렬 지원) +================================================================ */ + +export async function getQnaList(input: GetQnaSchema) { + return unstable_cache( + async () => { + try { + const offset = (input.page - 1) * input.perPage; + const advancedTable = input.flags.includes("advancedTable") || true; + + // 현재 사용자 ID (내 답변 여부 확인용) + const currentUserId = await getCurrentUserId(); + + // 고급 필터링 WHERE 절 구성 + const advancedWhere = advancedTable + ? filterColumns({ + table: qnaView, + filters: input.filters, + joinOperator: input.joinOperator, + }) + : undefined; + + // 전역 검색 WHERE 절 + let globalWhere; + if (input.search) { + const searchPattern = `%${input.search}%`; + globalWhere = or( + ilike(qnaView.title, searchPattern), + ilike(qnaView.content, searchPattern), + ilike(qnaView.authorName, searchPattern), + ilike(qnaView.companyName, searchPattern) + ); + } + + // Q&A 특화 필터링 + const qnaSpecificWhere = and( + // 도메인 필터 + input.authorDomain.length > 0 + ? inArray(qnaView.authorDomain, input.authorDomain) + : undefined, + + // 벤더 타입 필터 + input.vendorType.length > 0 + ? inArray(qnaView.vendorType, input.vendorType) + : undefined, + + // 답변 유무 필터 (뷰에서 이미 계산된 값 사용) + input.hasAnswers === "answered" + ? eq(qnaView.hasAnswers, true) + : input.hasAnswers === "unanswered" + ? eq(qnaView.hasAnswers, false) + : undefined, + + // 내 질문만 보기 + input.myQuestions === "true" && currentUserId + ? eq(qnaView.author, currentUserId) + : undefined, + + // 날짜 범위 필터 + ); + + // 최종 WHERE 절 결합 + const finalWhere = and( + advancedWhere, + globalWhere, + qnaSpecificWhere + ); + + // 정렬 설정 + const orderBy = input.sort.length > 0 + ? input.sort.map((item) => + item.desc + ? desc(qnaView[item.id as keyof typeof qnaView]) + : asc(qnaView[item.id as keyof typeof qnaView]) + ) + : [desc(qnaView.lastActivityAt), desc(qnaView.createdAt)]; // 최근 활동순으로 기본 정렬 + + // 트랜잭션 내에서 데이터 조회 + const { data, total } = await db.transaction(async (tx) => { + const data = await selectQnaList(tx, { + where: finalWhere, + orderBy, + offset, + limit: input.perPage, + }); + + const total = await countQnaList(tx, finalWhere); + + // 내가 답변한 질문들 표시 (로그인한 경우만) + let dataWithMyAnswers = data; + if (currentUserId && data.length > 0) { + const qnaIds = data.map(q => q.id); + const myAnsweredIds = await getMyAnsweredQnaIds(tx, currentUserId, qnaIds); + + dataWithMyAnswers = data.map(q => ({ + ...q, + hasMyAnswer: myAnsweredIds.includes(q.id), + isMyQuestion: currentUserId === q.author, + })); + } else { + dataWithMyAnswers = data.map(q => ({ + ...q, + hasMyAnswer: false, + isMyQuestion: false, + })); + } + + return { data: dataWithMyAnswers, total }; + }); + + const pageCount = Math.ceil(total / input.perPage); + + return { + data, + pageCount, + total, + // 메타 정보 추가 + meta: { + currentPage: input.page, + perPage: input.perPage, + hasNextPage: input.page < pageCount, + hasPrevPage: input.page > 1, + } + }; + } catch (err) { + console.error("Q&A 목록 조회 실패:", err); + return { + data: [], + pageCount: 0, + total: 0, + meta: { + currentPage: 1, + perPage: input.perPage, + hasNextPage: false, + hasPrevPage: false, + } + }; + } + }, + [JSON.stringify(input)], // 캐싱 키 + { + revalidate: 1800, // 30분 캐싱 + tags: ["qna", "qna-list"], + } + )(); +} + +/* ================================================================ + 2) Q&A 상세 조회 +================================================================ */ + +export async function getQnaById(id: number) { + return unstable_cache( + async () => { + try { + const currentUserId = await getCurrentUserId(); + + return await db.transaction(async (tx) => { + // 질문 정보 조회 (뷰 사용으로 단순화) + const question = await selectQnaDetail(tx, id); + if (!question) return null; + + // 답변 목록 조회 (뷰 사용으로 단순화) + const answers = await selectAnswersByQnaId(tx, id); + + // 각 답변의 댓글들 조회 및 계층 구조 생성 + const answersWithComments = await Promise.all( + answers.map(async (answer) => { + const comments = await selectCommentsByAnswerId(tx, answer.id); + + // 댓글 계층 구조 생성 (뷰에서 이미 계층 정보 제공) + const commentMap = new Map(); + const rootComments: any[] = []; + + // 먼저 모든 댓글을 Map에 저장 + comments.forEach(comment => { + commentMap.set(comment.id, { + ...comment, + children: [], + isMyComment: currentUserId === comment.author, + }); + }); + + // 계층 구조 생성 + comments.forEach(comment => { + const commentWithChildren = commentMap.get(comment.id); + if (comment.parentCommentId) { + const parent = commentMap.get(comment.parentCommentId); + if (parent) { + parent.children.push(commentWithChildren); + } + } else { + rootComments.push(commentWithChildren); + } + }); + + return { + ...answer, + comments: rootComments, + isMyAnswer: currentUserId === answer.author, + }; + }) + ); + + return { + ...question, + answers: answersWithComments, + isMyQuestion: currentUserId === question.author, + // 뷰에서 이미 계산된 통계 정보 활용 + totalInteractions: question.totalAnswers + question.totalComments, + }; + }); + } catch (err) { + console.error("Q&A 상세 조회 실패:", err); + return null; + } + }, + [`qna-detail-${id}`], + { + revalidate: 1800, // 30분 캐싱 + tags: ["qna", `qna-${id}`], + } + )(); +} + +/* ================================================================ + 3) Q&A 생성/수정/삭제 (CRUD 액션들) +================================================================ */ + +/** + * 새로운 Q&A 질문 생성 + */ +export async function createQna(input: CreateQnaSchema) { + try { + const userId = await getAuthenticatedUser(); + + const [newQna] = await db.insert(qna).values({ + title: input.title, + content: input.content, + category: input.category, + author: userId, + }).returning(); + + revalidateTag("qna"); + revalidateTag("qna-list"); + + return { + success: true, + data: newQna, + message: "질문이 성공적으로 등록되었습니다." + }; + } catch (err) { + console.error("질문 생성 실패:", err); + return { + success: false, + error: err instanceof Error ? err.message : "질문 등록에 실패했습니다.", + data: null + }; + } +} + +/** + * Q&A 질문 수정 + */ +export async function updateQna(id: number, input: UpdateQnaSchema) { + unstable_noStore(); + try { + const userId = await getAuthenticatedUser(); + + // 권한 확인 + const [existing] = await db + .select({ + author: qna.author, + title: qna.title + }) + .from(qna) + .where(eq(qna.id, id)); + + if (!existing) { + return { success: false, error: "질문을 찾을 수 없습니다." }; + } + + if (existing.author !== userId) { + return { success: false, error: "수정 권한이 없습니다." }; + } + + const [updated] = await db + .update(qna) + .set({ + ...input, + updatedAt: new Date() + }) + .where(eq(qna.id, id)) + .returning(); + + revalidateTag("qna"); + revalidateTag("qna-list"); + revalidateTag(`qna-${id}`); + + return { + success: true, + data: updated, + message: "질문이 성공적으로 수정되었습니다." + }; + } catch (err) { + console.error("질문 수정 실패:", err); + return { + success: false, + error: err instanceof Error ? err.message : "질문 수정에 실패했습니다." + }; + } +} + +/** + * Q&A 질문 삭제 (소프트 삭제) + */ +export async function deleteQna(id: number) { + unstable_noStore(); + try { + const userId = await getAuthenticatedUser(); + + // 권한 확인 + const [existing] = await db + .select({ + author: qna.author, + title: qna.title + }) + .from(qna) + .where(eq(qna.id, id)); + + if (!existing) { + return { success: false, error: "질문을 찾을 수 없습니다." }; + } + + if (existing.author !== userId) { + return { success: false, error: "삭제 권한이 없습니다." }; + } + + await db + .update(qna) + .set({ + isDeleted: true, + deletedAt: new Date() + }) + .where(eq(qna.id, id)); + + revalidateTag("qna"); + revalidateTag("qna-list"); + revalidateTag(`qna-${id}`); + + return { + success: true, + message: "질문이 성공적으로 삭제되었습니다." + }; + } catch (err) { + console.error("질문 삭제 실패:", err); + return { + success: false, + error: err instanceof Error ? err.message : "질문 삭제에 실패했습니다." + }; + } +} + +/* ================================================================ + 4) 답변 관련 CRUD 액션들 +================================================================ */ + +/** + * 답변 생성 + */ +export async function createAnswer(input: CreateAnswerSchema) { + unstable_noStore(); + try { + const userId = await getAuthenticatedUser(); + + // 질문 존재 여부 확인 + const [questionExists] = await db + .select({ id: qna.id }) + .from(qna) + .where(and( + eq(qna.id, input.qnaId), + eq(qna.isDeleted, false) + )); + + if (!questionExists) { + return { success: false, error: "존재하지 않는 질문입니다." }; + } + + const [newAnswer] = await db.insert(qnaAnswer).values({ + qnaId: input.qnaId, + content: input.content, + author: userId, + }).returning(); + + revalidateTag("qna"); + revalidateTag("qna-list"); + revalidateTag(`qna-${input.qnaId}`); + + return { + success: true, + data: newAnswer, + message: "답변이 성공적으로 등록되었습니다." + }; + } catch (error) { + console.error("답변 생성 실패:", error); + return { + success: false, + error: error instanceof Error ? error.message : "답변 등록에 실패했습니다." + }; + } +} + +/** + * 답변 수정 + */ +export async function updateAnswer(id: number, input: UpdateAnswerSchema) { + unstable_noStore(); + try { + const userId = await getAuthenticatedUser(); + + // 권한 확인 + const [existing] = await db + .select({ + author: qnaAnswer.author, + qnaId: qnaAnswer.qnaId + }) + .from(qnaAnswer) + .where(eq(qnaAnswer.id, id)); + + if (!existing) { + return { success: false, error: "답변을 찾을 수 없습니다." }; + } + + if (existing.author !== userId) { + return { success: false, error: "수정 권한이 없습니다." }; + } + + const [updated] = await db + .update(qnaAnswer) + .set({ + content: input.content, + updatedAt: new Date() + }) + .where(eq(qnaAnswer.id, id)) + .returning(); + + revalidateTag("qna"); + revalidateTag("qna-list"); + revalidateTag(`qna-${existing.qnaId}`); + + return { + success: true, + data: updated, + message: "답변이 성공적으로 수정되었습니다." + }; + } catch (err) { + console.error("답변 수정 실패:", err); + return { + success: false, + error: err instanceof Error ? err.message : "답변 수정에 실패했습니다." + }; + } +} + +/** + * 답변 삭제 + */ +export async function deleteAnswer(id: number) { + unstable_noStore(); + try { + const userId = await getAuthenticatedUser(); + + // 권한 확인 + const [existing] = await db + .select({ + author: qnaAnswer.author, + qnaId: qnaAnswer.qnaId + }) + .from(qnaAnswer) + .where(eq(qnaAnswer.id, id)); + + if (!existing) { + return { success: false, error: "답변을 찾을 수 없습니다." }; + } + + if (existing.author !== userId) { + return { success: false, error: "삭제 권한이 없습니다." }; + } + + // 하드 삭제 (답변은 CASCADE로 댓글도 함께 삭제) + await db.delete(qnaAnswer).where(eq(qnaAnswer.id, id)); + + revalidateTag("qna"); + revalidateTag("qna-list"); + revalidateTag(`qna-${existing.qnaId}`); + + return { + success: true, + message: "답변이 성공적으로 삭제되었습니다." + }; + } catch (err) { + console.error("답변 삭제 실패:", err); + return { + success: false, + error: err instanceof Error ? err.message : "답변 삭제에 실패했습니다." + }; + } +} + +/* ================================================================ + 5) 댓글 관련 CRUD 액션들 +================================================================ */ + +/** + * 댓글 생성 + */ +export async function createComment(input: CreateCommentSchema) { + unstable_noStore(); + try { + const userId = await getAuthenticatedUser(); + + // 답변 존재 여부 확인 + const [answerExists] = await db + .select({ + id: qnaAnswer.id, + qnaId: qnaAnswer.qnaId + }) + .from(qnaAnswer) + .where(and( + eq(qnaAnswer.id, input.answerId), + eq(qnaAnswer.isDeleted, false) + )); + + if (!answerExists) { + return { success: false, error: "존재하지 않는 답변입니다." }; + } + + // 부모 댓글 존재 여부 확인 (대댓글인 경우) + if (input.parentCommentId) { + const [parentExists] = await db + .select({ id: qnaComments.id }) + .from(qnaComments) + .where(and( + eq(qnaComments.id, input.parentCommentId), + eq(qnaComments.answerId, input.answerId), + eq(qnaComments.isDeleted, false) + )); + + if (!parentExists) { + return { success: false, error: "존재하지 않는 부모 댓글입니다." }; + } + } + + const [newComment] = await db.insert(qnaComments).values({ + ...input, + author: userId, + }).returning(); + + revalidateTag("qna"); + revalidateTag("qna-list"); + revalidateTag(`qna-${answerExists.qnaId}`); + + return { + success: true, + data: newComment, + message: "댓글이 성공적으로 등록되었습니다." + }; + } catch (error) { + console.error("댓글 작성 실패:", error); + return { + success: false, + error: error instanceof Error ? error.message : "댓글 등록에 실패했습니다." + }; + } +} + +/** + * 댓글 수정 + */ +export async function updateComment(id: number, input: UpdateCommentSchema) { + unstable_noStore(); + try { + const userId = await getAuthenticatedUser(); + + // 권한 확인 및 질문 ID 가져오기 + const [existing] = await db + .select({ + author: qnaComments.author, + answerId: qnaComments.answerId, + qnaId: qnaAnswer.qnaId + }) + .from(qnaComments) + .leftJoin(qnaAnswer, eq(qnaComments.answerId, qnaAnswer.id)) + .where(eq(qnaComments.id, id)); + + if (!existing) { + return { success: false, error: "댓글을 찾을 수 없습니다." }; + } + + if (existing.author !== userId) { + return { success: false, error: "수정 권한이 없습니다." }; + } + + const [updated] = await db + .update(qnaComments) + .set({ + content: input.content, + updatedAt: new Date() + }) + .where(eq(qnaComments.id, id)) + .returning(); + + revalidateTag("qna"); + revalidateTag("qna-list"); + revalidateTag(`qna-${existing.qnaId}`); + + return { + success: true, + data: updated, + message: "댓글이 성공적으로 수정되었습니다." + }; + } catch (error) { + console.error("댓글 수정 실패:", error); + return { + success: false, + error: error instanceof Error ? error.message : "댓글 수정에 실패했습니다." + }; + } +} + +/** + * 댓글 삭제 + */ +export async function deleteComment(id: number) { + unstable_noStore(); + try { + const userId = await getAuthenticatedUser(); + + // 권한 확인 및 질문 ID 가져오기 + const [existing] = await db + .select({ + author: qnaComments.author, + answerId: qnaComments.answerId, + qnaId: qnaAnswer.qnaId + }) + .from(qnaComments) + .leftJoin(qnaAnswer, eq(qnaComments.answerId, qnaAnswer.id)) + .where(eq(qnaComments.id, id)); + + if (!existing) { + return { success: false, error: "댓글을 찾을 수 없습니다." }; + } + + if (existing.author !== userId) { + return { success: false, error: "삭제 권한이 없습니다." }; + } + + // 하드 삭제 (대댓글도 CASCADE로 함께 삭제) + await db.delete(qnaComments).where(eq(qnaComments.id, id)); + + revalidateTag("qna"); + revalidateTag("qna-list"); + revalidateTag(`qna-${existing.qnaId}`); + + return { + success: true, + message: "댓글이 성공적으로 삭제되었습니다." + }; + } catch (error) { + console.error("댓글 삭제 실패:", error); + return { + success: false, + error: error instanceof Error ? error.message : "댓글 삭제에 실패했습니다." + }; + } +} + +/* ================================================================ + 6) 답변별 댓글 목록 조회 +================================================================ */ + +export async function getCommentsByAnswerId(answerId: number) { + return unstable_cache( + async () => { + try { + const currentUserId = await getCurrentUserId(); + + return await db.transaction(async (tx) => { + const comments = await selectCommentsByAnswerId(tx, answerId); + + // 댓글 계층 구조 생성 + const commentMap = new Map(); + const rootComments: any[] = []; + + // 모든 댓글을 Map에 저장 + comments.forEach(comment => { + commentMap.set(comment.id, { + ...comment, + children: [], + isMyComment: currentUserId === comment.author, + }); + }); + + // 계층 구조 생성 + comments.forEach(comment => { + const commentWithChildren = commentMap.get(comment.id); + if (comment.parentCommentId) { + const parent = commentMap.get(comment.parentCommentId); + if (parent) { + parent.children.push(commentWithChildren); + } + } else { + rootComments.push(commentWithChildren); + } + }); + + return rootComments; + }); + } catch (error) { + console.error("댓글 조회 실패:", error); + return []; + } + }, + [`comments-${answerId}`], + { + revalidate: 1800, // 30분 캐싱 + tags: ["qna", `comments-${answerId}`], + } + )(); +} + +/* ================================================================ + 7) 통계 및 메타 정보 조회 +================================================================ */ + +/** + * Q&A 대시보드 통계 조회 + */ +export async function getQnaStats() { + return unstable_cache( + async () => { + try { + // 뷰를 사용하여 간단하게 통계 조회 + const [stats] = await db + .select({ + totalQuestions: count(), + answeredQuestions: sql<number>`COUNT(CASE WHEN ${qnaView.hasAnswers} = true THEN 1 END)`, + unansweredQuestions: sql<number>`COUNT(CASE WHEN ${qnaView.hasAnswers} = false THEN 1 END)`, + popularQuestions: sql<number>`COUNT(CASE WHEN ${qnaView.isPopular} = true THEN 1 END)`, + totalAnswers: sql<number>`SUM(${qnaView.totalAnswers})`, + totalComments: sql<number>`SUM(${qnaView.totalComments})`, + }) + .from(qnaView); + + // 최근 활동 통계 + const [recentStats] = await db + .select({ + questionsThisWeek: sql<number>`COUNT(CASE WHEN ${qnaView.createdAt} >= NOW() - INTERVAL '7 days' THEN 1 END)`, + questionsThisMonth: sql<number>`COUNT(CASE WHEN ${qnaView.createdAt} >= NOW() - INTERVAL '30 days' THEN 1 END)`, + activeQuestionsThisWeek: sql<number>`COUNT(CASE WHEN ${qnaView.lastActivityAt} >= NOW() - INTERVAL '7 days' THEN 1 END)`, + }) + .from(qnaView); + + return { + ...stats, + ...recentStats, + // 추가 계산 통계 + answerRate: stats.totalQuestions > 0 + ? Math.round((stats.answeredQuestions / stats.totalQuestions) * 100) + : 0, + avgAnswersPerQuestion: stats.totalQuestions > 0 + ? Math.round((stats.totalAnswers || 0) / stats.totalQuestions * 100) / 100 + : 0, + }; + } catch (err) { + console.error("Q&A 통계 조회 실패:", err); + return { + totalQuestions: 0, + answeredQuestions: 0, + unansweredQuestions: 0, + popularQuestions: 0, + totalAnswers: 0, + totalComments: 0, + questionsThisWeek: 0, + questionsThisMonth: 0, + activeQuestionsThisWeek: 0, + answerRate: 0, + avgAnswersPerQuestion: 0, + }; + } + }, + ["qna-stats"], + { + revalidate: 3600, // 1시간 캐싱 + tags: ["qna", "qna-stats"], + } + )(); +} + +/** + * 사용자별 Q&A 활동 통계 + */ +export async function getMyQnaActivity() { + try { + const userId = await getAuthenticatedUser(); + + return unstable_cache( + async () => { + const [myStats] = await db + .select({ + myQuestions: count(), + myAnsweredQuestions: sql<number>`COUNT(CASE WHEN ${qnaView.hasAnswers} = true THEN 1 END)`, + }) + .from(qnaView) + .where(eq(qnaView.author, userId)); + + const [myAnswers] = await db + .select({ + totalAnswers: count(), + }) + .from(qnaAnswerView) + .where(eq(qnaAnswerView.author, userId)); + + const [myComments] = await db + .select({ + totalComments: count(), + }) + .from(qnaCommentView) + .where(eq(qnaCommentView.author, userId)); + + return { + ...myStats, + ...myAnswers, + ...myComments, + }; + }, + [`my-qna-activity-${userId}`], + { + revalidate: 1800, // 30분 캐싱 + tags: ["qna", `user-activity-${userId}`], + } + )(); + } catch (err) { + return { + myQuestions: 0, + myAnsweredQuestions: 0, + totalAnswers: 0, + totalComments: 0, + }; + } +}
\ No newline at end of file diff --git a/lib/qna/table/create-qna-dialog.tsx b/lib/qna/table/create-qna-dialog.tsx new file mode 100644 index 00000000..d5af932b --- /dev/null +++ b/lib/qna/table/create-qna-dialog.tsx @@ -0,0 +1,203 @@ +"use client" + +import * as React from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm } from "react-hook-form" +import { toast } from "sonner" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" + +import { createQnaSchema, type CreateQnaSchema } from "@/lib/qna/validation" +import { createQna } from "../service" +import { QNA_CATEGORY_LABELS } from "@/db/schema" +import TiptapEditor from "@/components/qna/tiptap-editor" + +interface CreateQnaDialogProps { + open: boolean + onOpenChange: (open: boolean) => void +} + +export function CreateQnaDialog({ open, onOpenChange }: CreateQnaDialogProps) { + const [isCreatePending, startCreateTransition] = React.useTransition() + + const form = useForm<CreateQnaSchema>({ + resolver: zodResolver(createQnaSchema), + defaultValues: { + title: "", + content: "", + category: undefined, + }, + }) + + function onSubmit(input: CreateQnaSchema) { + startCreateTransition(async () => { + try { + const result = await createQna(input) + + if (result.success) { + toast.success(result.message || "질문이 성공적으로 등록되었습니다.") + form.reset() + onOpenChange(false) + } else { + toast.error(result.error || "질문 등록에 실패했습니다.") + } + } catch (error) { + toast.error("예기치 못한 오류가 발생했습니다.") + console.error("질문 생성 오류:", error) + } + }) + } + + // 다이얼로그가 닫힐 때 폼 리셋 + React.useEffect(() => { + if (!open) { + form.reset() + } + }, [open, form]) + + + console.log(form.getValues(),"생성") + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-6xl max-h-[90vh] flex flex-col"> + <DialogHeader className="flex-shrink-0"> + <DialogTitle>새 질문 작성</DialogTitle> + <DialogDescription> + 질문의 제목과 내용을 입력해주세요. 다른 사용자들이 이해하기 쉽도록 구체적으로 작성해주세요. + </DialogDescription> + </DialogHeader> + + {/* 폼 영역: flex-1으로 남은 공간 모두 사용 */} + <div className="flex-1 overflow-hidden"> + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="h-full flex flex-col space-y-4"> + {/* 카테고리와 제목은 스크롤 없이 고정 */} + <div className="flex-shrink-0 space-y-4"> + {/* 카테고리 선택 */} + <FormField + control={form.control} + name="category" + render={({ field }) => ( + <FormItem> + <FormLabel>카테고리 *</FormLabel> + <Select + onValueChange={field.onChange} + defaultValue={field.value} + disabled={isCreatePending} + > + <FormControl> + <SelectTrigger> + <SelectValue placeholder="카테고리를 선택해주세요" /> + </SelectTrigger> + </FormControl> + <SelectContent> + {QNA_CATEGORY_LABELS.map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + {/* 제목 입력 */} + <FormField + control={form.control} + name="title" + render={({ field }) => ( + <FormItem> + <FormLabel>제목 *</FormLabel> + <FormControl> + <Input + placeholder="질문의 제목을 입력해주세요" + disabled={isCreatePending} + className="text-base" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + + {/* 내용 입력 영역: 고정 높이로 스크롤 생성 */} + <FormField + control={form.control} + name="content" + render={({ field }) => ( + <FormItem className="flex-1 flex flex-col min-h-0"> + <FormLabel className="flex-shrink-0">내용 *</FormLabel> + <FormControl className="flex-1 min-h-0"> + {/* 고정 높이 400px로 설정하여 스크롤 보장 */} + <div className="h-[400px]"> + <TiptapEditor + content={field.value} + setContent={field.onChange} + disabled={isCreatePending} + height="400px" + /> + </div> + </FormControl> + <FormMessage className="flex-shrink-0" /> + <div className="text-sm text-muted-foreground flex-shrink-0 mt-2"> + • 문제 상황을 구체적으로 설명해주세요<br/> + • 이미지 복사&붙여넣기, 드래그&드롭 지원<br/> + • 예상하는 결과와 실제 결과를 명시해주세요 + </div> + </FormItem> + )} + /> + </form> + </Form> + </div> + + <DialogFooter className="gap-2 pt-4 border-t flex-shrink-0"> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isCreatePending} + > + 취소 + </Button> + <Button + type="submit" + onClick={form.handleSubmit(onSubmit)} + disabled={isCreatePending} + > + {isCreatePending ? "등록 중..." : "질문 등록"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/qna/table/delete-qna-dialog.tsx b/lib/qna/table/delete-qna-dialog.tsx new file mode 100644 index 00000000..55dcd366 --- /dev/null +++ b/lib/qna/table/delete-qna-dialog.tsx @@ -0,0 +1,250 @@ +"use client" + +import * as React from "react" +import { toast } from "sonner" +import { Trash2 } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Drawer, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer" +import { Badge } from "@/components/ui/badge" +import { ScrollArea } from "@/components/ui/scroll-area" + +import { QnaViewSelect } from "@/db/schema" +import { useMediaQuery } from "@/hooks/use-media-query" +import { deleteQna } from "../service" + +interface DeleteQnaDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + qnas: QnaViewSelect[] + showTrigger?: boolean + onSuccess?: () => void +} + +export function DeleteQnaDialog({ + open, + onOpenChange, + qnas, + showTrigger = true, + onSuccess +}: DeleteQnaDialogProps) { + const [isDeletePending, startDeleteTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + + const qnaCount = qnas.length + const isMultiple = qnaCount > 1 + + async function handleDelete() { + startDeleteTransition(async () => { + try { + const promises = qnas.map(qna => deleteQna(qna.id)) + const results = await Promise.all(promises) + + const successCount = results.filter(result => result.success).length + const failCount = results.length - successCount + + if (successCount > 0) { + toast.success( + isMultiple + ? `${successCount}개의 질문이 삭제되었습니다.` + : "질문이 삭제되었습니다." + ) + onSuccess?.() + } + + if (failCount > 0) { + toast.error( + isMultiple + ? `${failCount}개의 질문 삭제에 실패했습니다.` + : "질문 삭제에 실패했습니다." + ) + } + + if (successCount > 0) { + onOpenChange(false) + } + } catch (error) { + toast.error("삭제 중 오류가 발생했습니다.") + console.error("질문 삭제 오류:", error) + } + }) + } + + const title = isMultiple ? `${qnaCount}개 질문 삭제` : "질문 삭제" + const description = isMultiple + ? "선택한 질문들을 정말로 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다." + : "이 질문을 정말로 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다." + + if (isDesktop) { + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + {showTrigger && ( + <DialogTrigger asChild> + <Button variant="outline" size="sm"> + <Trash2 className="mr-2 h-4 w-4" /> + 삭제 + </Button> + </DialogTrigger> + )} + <DialogContent className="max-w-2xl"> + <DialogHeader> + <DialogTitle>{title}</DialogTitle> + <DialogDescription>{description}</DialogDescription> + </DialogHeader> + + {/* 삭제할 질문 목록 */} + <div className="max-h-[300px]"> + <div className="text-sm font-medium mb-2">삭제 대상:</div> + <ScrollArea className="max-h-[250px] border rounded-md p-3"> + <div className="space-y-3"> + {qnas.map((qna, index) => ( + <div key={qna.id} className="flex items-start gap-3 p-3 border rounded-md bg-muted/50"> + <div className="font-mono text-xs text-muted-foreground mt-1"> + {index + 1}. + </div> + <div className="flex-1 min-w-0"> + <div className="font-medium text-sm line-clamp-2 mb-1"> + {qna.title} + </div> + <div className="flex items-center gap-2 text-xs text-muted-foreground"> + <span>{qna.authorName}</span> + <span>•</span> + <span>{qna.companyName || "미지정"}</span> + <span>•</span> + <span>답변 {qna.totalAnswers}개</span> + </div> + <div className="flex items-center gap-1 mt-2"> + {qna.hasAnswers && ( + <Badge variant="secondary" className="text-xs"> + 답변있음 + </Badge> + )} + {qna.isPopular && ( + <Badge variant="default" className="text-xs"> + 인기질문 + </Badge> + )} + </div> + </div> + </div> + ))} + </div> + </ScrollArea> + </div> + + {/* 경고 메시지 */} + <div className="rounded-md border border-destructive/20 bg-destructive/5 p-3"> + <div className="text-sm text-destructive"> + <strong>주의:</strong> 질문을 삭제하면 해당 질문의 모든 답변과 댓글도 함께 삭제됩니다. + </div> + </div> + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isDeletePending} + > + 취소 + </Button> + <Button + type="button" + variant="destructive" + onClick={handleDelete} + disabled={isDeletePending} + > + {isDeletePending ? "삭제 중..." : "삭제"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) + } + + return ( + <Drawer open={open} onOpenChange={onOpenChange}> + {showTrigger && ( + <DrawerTrigger asChild> + <Button variant="outline" size="sm"> + <Trash2 className="mr-2 h-4 w-4" /> + 삭제 + </Button> + </DrawerTrigger> + )} + <DrawerContent> + <DrawerHeader> + <DrawerTitle>{title}</DrawerTitle> + <DrawerDescription>{description}</DrawerDescription> + </DrawerHeader> + + <div className="px-4 pb-4"> + {/* 삭제할 질문 목록 */} + <div className="mb-4"> + <div className="text-sm font-medium mb-2">삭제 대상:</div> + <div className="max-h-[200px] space-y-2 overflow-y-auto"> + {qnas.map((qna, index) => ( + <div key={qna.id} className="flex items-start gap-2 p-2 border rounded-md bg-muted/50"> + <div className="font-mono text-xs text-muted-foreground mt-1"> + {index + 1}. + </div> + <div className="flex-1 min-w-0"> + <div className="font-medium text-sm line-clamp-1 mb-1"> + {qna.title} + </div> + <div className="text-xs text-muted-foreground"> + {qna.authorName} • 답변 {qna.totalAnswers}개 + </div> + </div> + </div> + ))} + </div> + </div> + + {/* 경고 메시지 */} + <div className="rounded-md border border-destructive/20 bg-destructive/5 p-3 mb-4"> + <div className="text-sm text-destructive"> + <strong>주의:</strong> 삭제된 질문과 관련 데이터는 복구할 수 없습니다. + </div> + </div> + </div> + + <DrawerFooter className="gap-2"> + <Button + type="button" + variant="destructive" + onClick={handleDelete} + disabled={isDeletePending} + > + {isDeletePending ? "삭제 중..." : "삭제"} + </Button> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isDeletePending} + > + 취소 + </Button> + </DrawerFooter> + </DrawerContent> + </Drawer> + ) +}
\ No newline at end of file diff --git a/lib/qna/table/improved-comment-section.tsx b/lib/qna/table/improved-comment-section.tsx new file mode 100644 index 00000000..ce32b706 --- /dev/null +++ b/lib/qna/table/improved-comment-section.tsx @@ -0,0 +1,319 @@ +import * as React from "react"; +import { useSession } from "next-auth/react"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { Badge } from "@/components/ui/badge"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { format } from "date-fns"; +import { Comment } from "@/lib/qna/types"; +import { + MessageCircle, + Trash2, + Edit, + Check, + X, + User, + Plus +} from "lucide-react"; + +interface ImprovedCommentSectionProps { + answerId: string | number; + comments: Comment[]; + onAddComment: (content: string) => Promise<void>; + onDeleteComment: (commentId: string | number) => Promise<void>; + onUpdateComment?: (commentId: string | number, content: string) => Promise<void>; +} + +export function ImprovedCommentSection({ + answerId, + comments, + onAddComment, + onDeleteComment, + onUpdateComment +}: ImprovedCommentSectionProps) { + const { data: session } = useSession(); + + // 상태 관리 + const [content, setContent] = React.useState(""); + const [isSubmitting, setIsSubmitting] = React.useState(false); + const [editingId, setEditingId] = React.useState<string | number | null>(null); + const [editContent, setEditContent] = React.useState(""); + const [showCommentForm, setShowCommentForm] = React.useState(false); + + // 댓글 작성 + const handleSubmit = async () => { + if (!content.trim() || !session?.user) return; + + setIsSubmitting(true); + try { + await onAddComment(content); + setContent(""); + setShowCommentForm(false); + } catch (error) { + console.error("댓글 작성 실패:", error); + } finally { + setIsSubmitting(false); + } + }; + + // 댓글 수정 시작 + const handleEditStart = (comment: Comment) => { + setEditingId(comment.id); + setEditContent(comment.content); + }; + + // 댓글 수정 취소 + const handleEditCancel = () => { + setEditingId(null); + setEditContent(""); + }; + + // 댓글 수정 저장 + const handleEditSave = async (commentId: string | number) => { + if (!editContent.trim() || !onUpdateComment) return; + + try { + await onUpdateComment(commentId, editContent); + setEditingId(null); + setEditContent(""); + } catch (error) { + console.error("댓글 수정 실패:", error); + } + }; + + return ( + <div className="space-y-4"> + {/* 헤더 */} + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <MessageCircle className="h-4 w-4 text-muted-foreground" /> + <span className="text-sm font-medium text-muted-foreground"> + 댓글 + </span> + {comments.length > 0 && ( + <Badge variant="secondary" className="text-xs h-5"> + {comments.length} + </Badge> + )} + </div> + + {session?.user && !showCommentForm && ( + <Button + variant="ghost" + size="sm" + onClick={() => setShowCommentForm(true)} + className="gap-2 text-xs" + > + <Plus className="h-3 w-3" /> + 댓글 작성 + </Button> + )} + </div> + + {/* 댓글 목록 */} + {comments.length > 0 && ( + <div className="space-y-3"> + {comments.map((comment) => ( + <div key={comment.id} className="group"> + <div className="flex gap-3"> + {/* 아바타 */} + <Avatar className="h-7 w-7 shrink-0"> + <AvatarImage src={comment.authorImageUrl} /> + <AvatarFallback className="text-xs"> + <User className="h-3 w-3" /> + </AvatarFallback> + </Avatar> + + {/* 댓글 내용 */} + <div className="flex-1 min-w-0"> + <div className="bg-muted/50 rounded-lg px-3 py-2"> + {/* 작성자 정보 */} + <div className="flex items-center gap-2 mb-1"> + <span className="text-sm font-medium"> + {comment.authorName || comment.author} + </span> + <span className="text-xs text-muted-foreground"> + {format(new Date(comment.createdAt), "MM월 dd일 HH:mm")} + </span> + </div> + + {/* 댓글 텍스트 */} + {editingId === comment.id ? ( + <div className="space-y-2"> + <Textarea + value={editContent} + onChange={(e) => setEditContent(e.target.value)} + className="min-h-[60px] text-sm resize-none" + maxLength={250} + placeholder="댓글을 입력하세요..." + /> + <div className="flex items-center justify-between"> + <span className="text-xs text-muted-foreground"> + {editContent.length}/250 + </span> + <div className="flex gap-1"> + <Button + variant="ghost" + size="sm" + onClick={() => handleEditSave(comment.id)} + disabled={!editContent.trim()} + className="h-7 px-2 text-green-600 hover:text-green-700 hover:bg-green-50" + > + <Check className="h-3 w-3" /> + </Button> + <Button + variant="ghost" + size="sm" + onClick={handleEditCancel} + className="h-7 px-2 text-muted-foreground hover:bg-muted" + > + <X className="h-3 w-3" /> + </Button> + </div> + </div> + </div> + ) : ( + <p className="text-sm leading-relaxed whitespace-pre-wrap"> + {comment.content} + </p> + )} + </div> + + {/* 액션 버튼들 */} + {session?.user?.id === comment.author && editingId !== comment.id && ( + <div className="flex gap-1 mt-1 opacity-0 group-hover:opacity-100 transition-opacity"> + {onUpdateComment && ( + <Button + variant="ghost" + size="sm" + onClick={() => handleEditStart(comment)} + className="h-6 px-2 text-xs text-muted-foreground hover:text-blue-600" + > + <Edit className="h-3 w-3 mr-1" /> + 수정 + </Button> + )} + + <AlertDialog> + <AlertDialogTrigger asChild> + <Button + variant="ghost" + size="sm" + className="h-6 px-2 text-xs text-muted-foreground hover:text-destructive" + > + <Trash2 className="h-3 w-3 mr-1" /> + 삭제 + </Button> + </AlertDialogTrigger> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>댓글 삭제</AlertDialogTitle> + <AlertDialogDescription> + 정말로 이 댓글을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다. + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel>취소</AlertDialogCancel> + <AlertDialogAction + onClick={() => onDeleteComment(comment.id)} + className="bg-destructive hover:bg-destructive/90" + > + 삭제 + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + </div> + )} + </div> + </div> + </div> + ))} + </div> + )} + + {/* 댓글 작성 폼 */} + {session?.user && showCommentForm && ( + <div className="space-y-3 pt-2 border-t"> + <div className="flex gap-3"> + <Avatar className="h-7 w-7 shrink-0"> + <AvatarImage src={session.user.image} /> + <AvatarFallback className="text-xs"> + <User className="h-3 w-3" /> + </AvatarFallback> + </Avatar> + <div className="flex-1 space-y-2"> + <Textarea + value={content} + onChange={(e) => { + if (e.target.value.length <= 250) { + setContent(e.target.value); + } + }} + placeholder="댓글을 입력하세요..." + className="min-h-[80px] resize-none" + maxLength={250} + disabled={isSubmitting} + /> + <div className="flex items-center justify-between"> + <span className="text-xs text-muted-foreground"> + {content.length}/250 + </span> + <div className="flex gap-2"> + <Button + variant="ghost" + size="sm" + onClick={() => { + setShowCommentForm(false); + setContent(""); + }} + disabled={isSubmitting} + > + 취소 + </Button> + <Button + size="sm" + onClick={handleSubmit} + disabled={isSubmitting || !content.trim()} + > + {isSubmitting ? "저장 중..." : "등록"} + </Button> + </div> + </div> + </div> + </div> + </div> + )} + + {/* 빈 상태 */} + {comments.length === 0 && !showCommentForm && ( + <div className="text-center py-6 text-muted-foreground"> + <MessageCircle className="h-8 w-8 mx-auto mb-2 opacity-50" /> + <p className="text-sm">아직 댓글이 없습니다.</p> + {session?.user && ( + <Button + variant="ghost" + size="sm" + onClick={() => setShowCommentForm(true)} + className="mt-2 gap-2" + > + <Plus className="h-3 w-3" /> + 첫 번째 댓글 작성하기 + </Button> + )} + </div> + )} + </div> + ); +}
\ No newline at end of file diff --git a/lib/qna/table/qna-detail.tsx b/lib/qna/table/qna-detail.tsx new file mode 100644 index 00000000..4f0a891f --- /dev/null +++ b/lib/qna/table/qna-detail.tsx @@ -0,0 +1,455 @@ +"use client"; + +import * as React from "react"; +import { useRouter } from "next/navigation"; +import { useSession } from "next-auth/react"; +import { format } from "date-fns"; + +import { Button } from "@/components/ui/button"; +import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { + ArrowLeft, + MessageSquare, + Edit, + Trash2, + Plus, + Clock, + User +} from "lucide-react"; + +import TiptapEditor from "@/components/qna/tiptap-editor"; +import { ImprovedCommentSection } from "./improved-comment-section"; +import { + createAnswer, + deleteQna, + deleteAnswer, + updateAnswer, + createComment, + deleteComment, + updateComment, +} from "@/lib/qna/service"; +import { Question } from "@/lib/qna/types"; + +interface QnaDetailProps { + question: Question; +} + +export default function QnaDetail({ question }: QnaDetailProps) { + const router = useRouter(); + const { data: session } = useSession(); + + // ------------------------------------------------------------------------- + // STATE + // ------------------------------------------------------------------------- + const [answerContent, setAnswerContent] = React.useState<string>(""); + const [loading, setLoading] = React.useState(false); + const [isAnswerDialogOpen, setIsAnswerDialogOpen] = React.useState(false); + const [editingAnswerId, setEditingAnswerId] = React.useState<number | null>(null); + + // ------------------------------------------------------------------------- + // DERIVED + // ------------------------------------------------------------------------- + const isAuthor = session?.user?.id === question.author; + const hasAnswers = (question.answers ?? []).length > 0; + const userAnswer = question.answers?.find((a) => a.author === session?.user?.id) ?? null; + + // ------------------------------------------------------------------------- + // HANDLERS – NAVIGATION + // ------------------------------------------------------------------------- + const handleGoBack = () => { + router.push("/evcp/qna"); + }; + + // ------------------------------------------------------------------------- + // HANDLERS – QUESTION + // ------------------------------------------------------------------------- + const handleDeleteQuestion = async () => { + try { + await deleteQna(question.id); + router.push("/evcp/qna"); + router.refresh(); + } catch (err) { + console.error("질문 삭제 실패:", err); + } + }; + + // ------------------------------------------------------------------------- + // HANDLERS – ANSWER + // ------------------------------------------------------------------------- + const handleSubmitAnswer = async () => { + if (!answerContent.trim()) return; + setLoading(true); + try { + await createAnswer({ qnaId: question.id, content: answerContent }); + setAnswerContent(""); + setIsAnswerDialogOpen(false); + router.refresh(); + } catch (err) { + console.error("답변 저장 실패:", err); + } finally { + setLoading(false); + } + }; + + const handleUpdateAnswer = async (answerId: number) => { + if (!answerContent.trim()) return; + setLoading(true); + try { + await updateAnswer(answerId, answerContent); + setAnswerContent(""); + setEditingAnswerId(null); + setIsAnswerDialogOpen(false); + router.refresh(); + } catch (err) { + console.error("답변 수정 실패:", err); + } finally { + setLoading(false); + } + }; + + const handleDeleteAnswer = async (id: number) => { + try { + await deleteAnswer(id); + router.refresh(); + } catch (err) { + console.error("답변 삭제 실패:", err); + } + }; + + const startEditingAnswer = (answer: any) => { + setAnswerContent(answer.content); + setEditingAnswerId(answer.id); + setIsAnswerDialogOpen(true); + }; + + const resetAnswerDialog = () => { + setAnswerContent(""); + setEditingAnswerId(null); + setIsAnswerDialogOpen(false); + }; + + return ( + <div className="min-h-screen bg-gray-50/50"> + {/* 헤더 */} + <div className="border-b bg-white"> + <div className="container mx-auto px-4 py-4"> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-4"> + <Button + variant="ghost" + size="sm" + onClick={handleGoBack} + className="gap-2" + > + <ArrowLeft className="h-4 w-4" /> + 목록으로 + </Button> + {/* <div className="h-6 w-px bg-border" /> */} + {/* <Badge variant="outline"> + {getCategoryLabel(question.category as any, 'ko')} + </Badge> */} + </div> + + {isAuthor && !hasAnswers && ( + <div className="flex items-center gap-2"> + <Button + variant="outline" + size="sm" + onClick={() => router.push(`/evcp/qna/${question.id}/edit`)} + className="gap-2" + > + <Edit className="h-4 w-4" /> + 수정 + </Button> + <AlertDialog> + <AlertDialogTrigger asChild> + <Button variant="outline" size="sm" className="gap-2 text-destructive hover:text-destructive"> + <Trash2 className="h-4 w-4" /> + 삭제 + </Button> + </AlertDialogTrigger> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>질문 삭제</AlertDialogTitle> + <AlertDialogDescription> + 정말로 이 질문을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다. + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel>취소</AlertDialogCancel> + <AlertDialogAction onClick={handleDeleteQuestion} className="bg-destructive hover:bg-destructive/90"> + 삭제 + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + </div> + )} + </div> + </div> + </div> + + {/* 메인 컨텐츠 */} + <div className="container mx-auto px-4 py-6 max-w-4xl"> + <div className="space-y-6"> + {/* 질문 영역 */} + <Card> + <CardHeader className="pb-4"> + <div className="space-y-3"> + <CardTitle className="text-2xl leading-tight"> + {question.title} + </CardTitle> + <div className="flex items-center gap-4 text-sm text-muted-foreground"> + <div className="flex items-center gap-2"> + <Avatar className="h-6 w-6"> + <AvatarImage src={question.authorImageUrl} /> + <AvatarFallback> + <User className="h-3 w-3" /> + </AvatarFallback> + </Avatar> + <span className="font-medium text-foreground"> + {question.authorName} + </span> + </div> + <div className="flex items-center gap-1"> + <Clock className="h-4 w-4" /> + <time dateTime={question.createdAt}> + {format(new Date(question.createdAt), "yyyy년 MM월 dd일 HH:mm")} + </time> + </div> + {hasAnswers && ( + <div className="flex items-center gap-1"> + <MessageSquare className="h-4 w-4" /> + <span>답변 {question.answers?.length}개</span> + </div> + )} + </div> + </div> + </CardHeader> + <CardContent> + <div + className="prose prose-sm max-w-none prose-headings:text-foreground prose-p:text-foreground prose-strong:text-foreground prose-em:text-foreground" + dangerouslySetInnerHTML={{ __html: question.content || "내용 없음" }} + /> + </CardContent> + </Card> + + {/* 답변 영역 */} + <div className="space-y-4"> + <div className="flex items-center justify-between"> + <h2 className="text-xl font-semibold flex items-center gap-2"> + <MessageSquare className="h-5 w-5" /> + 답변 {hasAnswers ? `(${question.answers?.length})` : ''} + </h2> + + {session?.user && ( + <Dialog open={isAnswerDialogOpen} onOpenChange={setIsAnswerDialogOpen}> + <DialogTrigger asChild> + <Button + className="gap-2" + onClick={() => { + if (!userAnswer) { + setAnswerContent(""); + setEditingAnswerId(null); + } + }} + > + <Plus className="h-4 w-4" /> + {userAnswer ? '답변 수정' : '답변 작성'} + </Button> + </DialogTrigger> + <DialogContent className="max-w-4xl max-h-[90vh]"> + <DialogHeader> + <DialogTitle> + {editingAnswerId ? '답변 수정' : '새 답변 작성'} + </DialogTitle> + <DialogDescription> + 질문에 대한 답변을 작성해주세요. 다른 사용자들이 이해하기 쉽도록 구체적으로 작성해주세요. + </DialogDescription> + </DialogHeader> + + <div className="flex-1 min-h-0"> + <TiptapEditor + content={answerContent} + setContent={setAnswerContent} + disabled={loading} + height="400px" + /> + </div> + + <DialogFooter className="gap-2 pt-4 border-t"> + <Button + variant="outline" + onClick={resetAnswerDialog} + disabled={loading} + > + 취소 + </Button> + <Button + onClick={() => editingAnswerId ? handleUpdateAnswer(editingAnswerId) : handleSubmitAnswer()} + disabled={loading || !answerContent.trim()} + > + {loading ? '저장 중...' : editingAnswerId ? '수정' : '등록'} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + )} + </div> + + {hasAnswers ? ( + <div className="space-y-4"> + {question.answers?.map((answer, index) => ( + <Card key={answer.id} className="relative"> + <CardHeader className="pb-3"> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-3"> + <Avatar className="h-8 w-8"> + <AvatarImage src={answer.authorImageUrl} /> + <AvatarFallback> + <User className="h-4 w-4" /> + </AvatarFallback> + </Avatar> + <div> + <div className="font-medium text-sm"> + {answer.authorName ?? `사용자 ${answer.author}`} + </div> + <div className="text-xs text-muted-foreground"> + {format(new Date(answer.createdAt), "yyyy년 MM월 dd일 HH:mm")} + </div> + </div> + </div> + + {session?.user?.id === answer.author && ( + <div className="flex items-center gap-1"> + <Button + variant="ghost" + size="sm" + onClick={() => startEditingAnswer(answer)} + className="h-8 gap-1 text-muted-foreground hover:text-blue-600" + > + <Edit className="h-3 w-3" /> + 수정 + </Button> + <AlertDialog> + <AlertDialogTrigger asChild> + <Button + variant="ghost" + size="sm" + className="h-8 gap-1 text-muted-foreground hover:text-destructive" + > + <Trash2 className="h-3 w-3" /> + 삭제 + </Button> + </AlertDialogTrigger> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>답변 삭제</AlertDialogTitle> + <AlertDialogDescription> + 정말로 이 답변을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다. + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel>취소</AlertDialogCancel> + <AlertDialogAction + onClick={() => handleDeleteAnswer(answer.id)} + className="bg-destructive hover:bg-destructive/90" + > + 삭제 + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + </div> + )} + </div> + </CardHeader> + <CardContent className="pt-0"> + <div + className="prose prose-sm max-w-none prose-headings:text-foreground prose-p:text-foreground prose-strong:text-foreground prose-em:text-foreground" + dangerouslySetInnerHTML={{ __html: answer.content }} + /> + + {/* 답변별 댓글 섹션 */} + <div className="mt-6 pt-4 border-t"> + <ImprovedCommentSection + answerId={answer.id} + comments={answer.comments ?? []} + onAddComment={async (content) => { + if (!session?.user?.id) return; + try { + await createComment({ content, answerId: answer.id }); + router.refresh(); + } catch (err) { + console.error("댓글 작성 실패:", err); + } + }} + onDeleteComment={async (commentId) => { + try { + await deleteComment(commentId); + router.refresh(); + } catch (err) { + console.error("댓글 삭제 실패:", err); + } + }} + onUpdateComment={async (commentId, content) => { + try { + await updateComment(commentId, content); + router.refresh(); + } catch (err) { + console.error("댓글 수정 실패:", err); + } + }} + /> + </div> + </CardContent> + </Card> + ))} + </div> + ) : ( + <Card className="py-12"> + <CardContent className="text-center"> + <MessageSquare className="h-12 w-12 text-muted-foreground mx-auto mb-4" /> + <p className="text-muted-foreground mb-4"> + 아직 등록된 답변이 없습니다. + </p> + {session?.user && ( + <Button + onClick={() => setIsAnswerDialogOpen(true)} + className="gap-2" + > + <Plus className="h-4 w-4" /> + 첫 번째 답변 작성하기 + </Button> + )} + </CardContent> + </Card> + )} + </div> + </div> + </div> + </div> + ); +}
\ No newline at end of file diff --git a/lib/qna/table/qna-export-actions.tsx b/lib/qna/table/qna-export-actions.tsx new file mode 100644 index 00000000..07b692c0 --- /dev/null +++ b/lib/qna/table/qna-export-actions.tsx @@ -0,0 +1,261 @@ +"use server" + +import { QnaViewSelect } from "@/db/schema" + +interface ExportQnaDataParams { + format: "csv" | "excel" + data: QnaViewSelect[] + fields: string[] +} + +/** + * Q&A 데이터 내보내기 + */ +export async function exportQnaData({ + format, + data, + fields +}: ExportQnaDataParams) { + try { + // 필드 매핑 + const fieldMapping: Record<string, string> = { + title: "제목", + authorName: "작성자", + companyName: "회사명", + authorDomain: "도메인", + vendorType: "벤더타입", + totalAnswers: "답변수", + totalComments: "댓글수", + createdAt: "작성일", + lastActivityAt: "최근활동", + hasAnswers: "답변여부", + isPopular: "인기질문", + } + + // 데이터 변환 + const exportData = data.map(qna => { + const row: Record<string, any> = {} + + fields.forEach(field => { + const label = fieldMapping[field] || field + + switch (field) { + case "createdAt": + case "lastActivityAt": + row[label] = qna[field as keyof QnaViewSelect] + ? new Date(qna[field as keyof QnaViewSelect] as string).toLocaleDateString("ko-KR") + : "" + break + case "hasAnswers": + row[label] = qna.hasAnswers ? "예" : "아니오" + break + case "isPopular": + row[label] = qna.isPopular ? "예" : "아니오" + break + case "authorDomain": + const domainLabels: Record<string, string> = { + partners: "협력업체", + tech: "기술업체", + admin: "관리자" + } + row[label] = domainLabels[qna.authorDomain as string] || qna.authorDomain + break + case "vendorType": + const typeLabels: Record<string, string> = { + vendor: "일반벤더", + techVendor: "기술벤더" + } + row[label] = typeLabels[qna.vendorType as string] || qna.vendorType || "" + break + default: + row[label] = qna[field as keyof QnaViewSelect] || "" + } + }) + + return row + }) + + if (format === "csv") { + return generateCSV(exportData) + } else { + return generateExcel(exportData) + } + } catch (error) { + console.error("내보내기 오류:", error) + return { + success: false, + error: "데이터 내보내기 중 오류가 발생했습니다." + } + } +} + +/** + * CSV 파일 생성 + */ +function generateCSV(data: Record<string, any>[]) { + try { + if (data.length === 0) { + return { + success: false, + error: "내보낼 데이터가 없습니다." + } + } + + const headers = Object.keys(data[0]) + const csvContent = [ + headers.join(","), // 헤더 + ...data.map(row => + headers.map(header => { + const value = row[header] + // CSV에서 쉼표와 따옴표 이스케이프 + if (typeof value === "string" && (value.includes(",") || value.includes('"'))) { + return `"${value.replace(/"/g, '""')}"` + } + return value + }).join(",") + ) + ].join("\n") + + // BOM 추가 (한글 인코딩을 위해) + const csvWithBOM = "\uFEFF" + csvContent + const blob = new Blob([csvWithBOM], { type: "text/csv;charset=utf-8;" }) + + // 파일 다운로드 처리는 클라이언트에서 수행 + const url = URL.createObjectURL(blob) + const fileName = `qna_export_${new Date().toISOString().split('T')[0]}.csv` + + // 클라이언트에서 다운로드 처리 + if (typeof window !== "undefined") { + const link = document.createElement("a") + link.href = url + link.download = fileName + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) + } + + return { + success: true, + message: "CSV 파일이 다운로드되었습니다." + } + } catch (error) { + return { + success: false, + error: "CSV 생성 중 오류가 발생했습니다." + } + } +} + +/** + * Excel 파일 생성 + */ +function generateExcel(data: Record<string, any>[]) { + try { + if (data.length === 0) { + return { + success: false, + error: "내보낼 데이터가 없습니다." + } + } + + // Excel 생성을 위해 SheetJS 라이브러리 사용 + // 실제 구현에서는 xlsx 라이브러리를 사용해야 함 + + const headers = Object.keys(data[0]) + const worksheet = [ + headers, // 헤더 행 + ...data.map(row => headers.map(header => row[header])) // 데이터 행들 + ] + + // 간단한 CSV 형태로 반환 (실제로는 xlsx 라이브러리 사용 권장) + const csvContent = worksheet.map(row => + row.map(cell => { + if (typeof cell === "string" && (cell.includes(",") || cell.includes('"'))) { + return `"${cell.replace(/"/g, '""')}"` + } + return cell + }).join(",") + ).join("\n") + + const csvWithBOM = "\uFEFF" + csvContent + const blob = new Blob([csvWithBOM], { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + }) + + const url = URL.createObjectURL(blob) + const fileName = `qna_export_${new Date().toISOString().split('T')[0]}.xlsx` + + // 클라이언트에서 다운로드 처리 + if (typeof window !== "undefined") { + const link = document.createElement("a") + link.href = url + link.download = fileName + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) + } + + return { + success: true, + message: "Excel 파일이 다운로드되었습니다." + } + } catch (error) { + return { + success: false, + error: "Excel 생성 중 오류가 발생했습니다." + } + } +} + +/** + * Q&A 통계 내보내기 + */ +export async function exportQnaStats(data: QnaViewSelect[]) { + try { + const stats = { + 총질문수: data.length, + 답변된질문: data.filter(q => q.hasAnswers).length, + 답변대기질문: data.filter(q => !q.hasAnswers).length, + 인기질문: data.filter(q => q.isPopular).length, + 평균답변수: data.reduce((sum, q) => sum + (q.totalAnswers || 0), 0) / data.length, + 평균댓글수: data.reduce((sum, q) => sum + (q.totalComments || 0), 0) / data.length, + } + + // 도메인별 통계 + const domainStats = data.reduce((acc, q) => { + const domain = q.authorDomain || "기타" + acc[domain] = (acc[domain] || 0) + 1 + return acc + }, {} as Record<string, number>) + + // 회사별 통계 (상위 10개) + const companyStats = data.reduce((acc, q) => { + const company = q.companyName || "미지정" + acc[company] = (acc[company] || 0) + 1 + return acc + }, {} as Record<string, number>) + + const topCompanies = Object.entries(companyStats) + .sort(([,a], [,b]) => b - a) + .slice(0, 10) + + const exportData = [ + { 구분: "전체 통계", ...stats }, + { 구분: "도메인별", ...domainStats }, + ...topCompanies.map(([company, count]) => ({ + 구분: "회사별", + 회사명: company, + 질문수: count + })) + ] + + return generateCSV(exportData) + } catch (error) { + return { + success: false, + error: "통계 내보내기 중 오류가 발생했습니다." + } + } +}
\ No newline at end of file diff --git a/lib/qna/table/qna-table-columns.tsx b/lib/qna/table/qna-table-columns.tsx new file mode 100644 index 00000000..01431e35 --- /dev/null +++ b/lib/qna/table/qna-table-columns.tsx @@ -0,0 +1,325 @@ +"use client" + +import * as React from "react" +import { type ColumnDef } from "@tanstack/react-table" +import { useRouter } from "next/navigation" +import { + MoreHorizontal, + Eye, + Edit, + Trash2, + MessageSquare, + MessageCircle, + Clock, + Building2, + User, + CheckCircle2, + AlertCircle, + TrendingUp +} from "lucide-react" + +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Badge } from "@/components/ui/badge" +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" + +import { formatDate } from "@/lib/utils" +import { QnaViewSelect } from "@/db/schema" +import type { DataTableRowAction } from "@/types/table" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" + + +type NextRouter = ReturnType<typeof useRouter>; + +interface GetColumnsOptions { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<QnaViewSelect> | null>> + router: NextRouter; + currentUserId?: number | string; // ← 추가 + +} + +export function getColumns({ setRowAction, router, currentUserId }: GetColumnsOptions): ColumnDef<QnaViewSelect>[] { + return [ + // 선택 체크박스 + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="모두 선택" + className="translate-y-[2px]" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="행 선택" + className="translate-y-[2px]" + /> + ), + enableSorting: false, + enableHiding: false, + }, + + // 제목 (클릭 시 상세 페이지 이동) + { + accessorKey: "title", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="제목" /> + ), + cell: ({ row }) => { + const qna = row.original + + return ( + <div className="flex flex-col gap-1"> + <Button + variant="link" + className="h-auto p-0 text-left justify-start font-medium text-foreground hover:text-primary" + onClick={() => router.push(`/evcp/qna/${qna.id}`)} + > + <span className="line-clamp-2 max-w-[300px]"> + {qna.title} + </span> + </Button> + + {/* 상태 배지들 */} + <div className="flex items-center gap-1 flex-wrap"> + {qna.hasAnswers && ( + <Badge variant="secondary" className="text-xs"> + <CheckCircle2 className="w-3 h-3 mr-1" /> + 답변됨 + </Badge> + )} + {!qna.hasAnswers && ( + <Badge variant="outline" className="text-xs"> + <AlertCircle className="w-3 h-3 mr-1" /> + 답변 대기 + </Badge> + )} + {qna.isPopular && ( + <Badge variant="default" className="text-xs"> + <TrendingUp className="w-3 h-3 mr-1" /> + 인기 + </Badge> + )} + </div> + </div> + ) + }, + enableSorting: true, + enableHiding: false, + }, + + // 작성자 정보 + { + accessorKey: "authorName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="작성자" /> + ), + cell: ({ row }) => { + const qna = row.original + + return ( + <div className="flex items-center gap-2"> + <Avatar className="h-8 w-8"> + <AvatarImage src={qna.authorImageUrl || undefined} /> + <AvatarFallback> + {qna.authorName?.slice(0, 2) || "??"} + </AvatarFallback> + </Avatar> + <div className="flex flex-col"> + <span className="font-medium text-sm">{qna.authorName}</span> + <span className="text-xs text-muted-foreground"> + {qna.authorEmail} + </span> + </div> + </div> + ) + }, + enableSorting: true, + }, + + // 회사 정보 + { + accessorKey: "companyName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple + column={column} + title="회사" + /> + ), + cell: ({ row }) => { + const qna = row.original + + return ( + <div className="flex flex-col gap-1"> + <span className="font-medium text-sm"> + {qna.companyName || "미지정"} + </span> + {qna.vendorType && ( + <span + className="text-xs w-fit" + > + {qna.vendorType === "vendor" ? "일반 벤더" : "기술 벤더"} + </span> + )} + </div> + ) + }, + enableSorting: true, + }, + + // 도메인 + { + accessorKey: "category", + header: "카테고리", + cell: ({ row }) => { + const domain = row.original.category + return ( + <Badge variant="outline" className="text-xs"> + {domain} + </Badge> + ) + }, + enableSorting: true, + }, + + // 답변/댓글 통계 + { + id: "statistics", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="활동" /> + ), + cell: ({ row }) => { + const qna = row.original + + return ( + <div className="flex items-center gap-3"> + <Tooltip> + <TooltipTrigger asChild> + <div className="flex items-center gap-1 text-sm"> + <MessageSquare className="h-4 w-4 text-blue-500" /> + <span className="font-medium">{qna.totalAnswers || 0}</span> + </div> + </TooltipTrigger> + <TooltipContent>답변 수</TooltipContent> + </Tooltip> + + <Tooltip> + <TooltipTrigger asChild> + <div className="flex items-center gap-1 text-sm"> + <MessageCircle className="h-4 w-4 text-green-500" /> + <span className="font-medium">{qna.totalComments || 0}</span> + </div> + </TooltipTrigger> + <TooltipContent>댓글 수</TooltipContent> + </Tooltip> + </div> + ) + }, + enableSorting: false, + }, + + // 작성일 + { + accessorKey: "createdAt", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="작성일" /> + ), + cell: ({ row }) => ( + <div className="text-sm"> + {formatDate(row.original.createdAt)} + </div> + ), + enableSorting: true, + }, + + // 최근 활동 + { + accessorKey: "lastActivityAt", + header: ({ column }) => ( + <DataTableColumnHeaderSimple + column={column} + title="최근 활동" + /> + ), + cell: ({ row }) => { + const lastActivity = row.original.lastActivityAt + + return ( + <div className="text-sm"> + {lastActivity ? formatDate(lastActivity) : "없음"} + </div> + ) + }, + enableSorting: true, + }, + + // 액션 메뉴 + { + id: "actions", + cell: ({ row }) => { + const qna = row.original + const isAuthor = qna.author === currentUserId + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="메뉴 열기" + variant="ghost" + className="flex h-8 w-8 p-0 data-[state=open]:bg-muted" + > + <MoreHorizontal className="h-4 w-4" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-[160px]"> + {/* ───────── 공통 : 상세 보기 ───────── */} + <DropdownMenuItem onClick={() => router.push(`/evcp/qna/${qna.id}`)}> + <Eye className="mr-2 h-4 w-4" /> + 상세보기 + </DropdownMenuItem> + + {/* ───────── 본인 글일 때만 노출 ───────── */} + {isAuthor && ( + <> + <DropdownMenuItem onClick={() => setRowAction({ type: "update", row })}> + <Edit className="mr-2 h-4 w-4" /> + 수정 + </DropdownMenuItem> + + <DropdownMenuSeparator /> + + <DropdownMenuItem + onClick={() => setRowAction({ type: "delete", row })} + className="text-destructive focus:text-destructive" + > + <Trash2 className="mr-2 h-4 w-4" /> + 삭제 + <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut> + </DropdownMenuItem> + </> + )} + </DropdownMenuContent> + </DropdownMenu> + ) + }, + enableSorting: false, + enableHiding: false, + }, + ] +}
\ No newline at end of file diff --git a/lib/qna/table/qna-table-toolbar-actions.tsx b/lib/qna/table/qna-table-toolbar-actions.tsx new file mode 100644 index 00000000..d3e8623e --- /dev/null +++ b/lib/qna/table/qna-table-toolbar-actions.tsx @@ -0,0 +1,176 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Download, Plus, RefreshCw, FileSpreadsheet, FileText, Users } from "lucide-react" +import { toast } from "sonner" + +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" + +import { QnaViewSelect } from "@/db/schema" +import { exportQnaData } from "./qna-export-actions" + +interface QnaTableToolbarActionsProps { + table: Table<QnaViewSelect> + domain: string + onCreateClick: () => void +} + +export function QnaTableToolbarActions({ + table, + domain, + onCreateClick +}: QnaTableToolbarActionsProps) { + const [isExporting, setIsExporting] = React.useState(false) + const [isRefreshing, setIsRefreshing] = React.useState(false) + + // 선택된 행들 + const selectedRows = table.getFilteredSelectedRowModel().rows + const selectedCount = selectedRows.length + + // 새로고침 + const handleRefresh = async () => { + setIsRefreshing(true) + try { + // 페이지 새로고침 또는 데이터 재요청 + window.location.reload() + } catch (error) { + toast.error("새로고침 중 오류가 발생했습니다.") + } finally { + setIsRefreshing(false) + } + } + + // 데이터 내보내기 + const handleExport = async (format: "csv" | "excel") => { + setIsExporting(true) + try { + const selectedData = selectedCount > 0 + ? selectedRows.map(row => row.original) + : table.getFilteredRowModel().rows.map(row => row.original) + + const result = await exportQnaData({ + format, + data: selectedData, + fields: [ + "title", + "authorName", + "companyName", + "totalAnswers", + "totalComments", + "createdAt", + "lastActivityAt" + ] + }) + + if (result.success) { + toast.success(`${format.toUpperCase()} 파일이 다운로드되었습니다.`) + } else { + toast.error(result.error || "내보내기에 실패했습니다.") + } + } catch (error) { + toast.error("내보내기 중 오류가 발생했습니다.") + console.error("Export error:", error) + } finally { + setIsExporting(false) + } + } + + return ( + <div className="flex items-center gap-2"> + {/* 선택된 항목 수 표시 */} + {selectedCount > 0 && ( + <div className="flex items-center gap-2 text-sm text-muted-foreground"> + <Users className="h-4 w-4" /> + <span>{selectedCount}개 선택됨</span> + </div> + )} + + {/* 새 질문 작성 버튼 */} + {domain === "partners" && + <Tooltip> + <TooltipTrigger asChild> + <Button + onClick={onCreateClick} + size="sm" + className="h-8 gap-2" + > + <Plus className="h-4 w-4" /> + 새 질문 + </Button> + </TooltipTrigger> + <TooltipContent>새로운 질문을 작성합니다</TooltipContent> + </Tooltip> +} + + {/* 내보내기 드롭다운 */} + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + variant="outline" + size="sm" + className="h-8 gap-2" + disabled={isExporting} + > + <Download className="h-4 w-4" /> + {isExporting ? "내보내는 중..." : "내보내기"} + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-[180px]"> + <DropdownMenuItem + onClick={() => handleExport("csv")} + disabled={isExporting} + > + <FileText className="mr-2 h-4 w-4" /> + CSV 파일로 내보내기 + </DropdownMenuItem> + <DropdownMenuItem + onClick={() => handleExport("excel")} + disabled={isExporting} + > + <FileSpreadsheet className="mr-2 h-4 w-4" /> + Excel 파일로 내보내기 + </DropdownMenuItem> + <DropdownMenuSeparator /> + <DropdownMenuItem + onClick={() => { + const rowCount = selectedCount > 0 ? selectedCount : table.getFilteredRowModel().rows.length + toast.info(`${rowCount}개의 질문이 내보내집니다.`) + }} + disabled={isExporting} + className="text-xs text-muted-foreground" + > + {selectedCount > 0 + ? `선택된 ${selectedCount}개 항목` + : `전체 ${table.getFilteredRowModel().rows.length}개 항목` + } + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + + {/* 새로고침 버튼 */} + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="outline" + size="sm" + onClick={handleRefresh} + disabled={isRefreshing} + className="h-8 w-8 p-0" + > + <RefreshCw className={`h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} /> + </Button> + </TooltipTrigger> + <TooltipContent>목록 새로고침</TooltipContent> + </Tooltip> + </div> + ) +}
\ No newline at end of file diff --git a/lib/qna/table/qna-table.tsx b/lib/qna/table/qna-table.tsx new file mode 100644 index 00000000..45efc124 --- /dev/null +++ b/lib/qna/table/qna-table.tsx @@ -0,0 +1,236 @@ +"use client" + +import * as React from "react" +import { qnaView, type QnaViewSelect } from "@/db/schema" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" + +import { toSentenceCase } from "@/lib/utils" +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import { DataTableToolbar } from "@/components/data-table/data-table-toolbar" + +import { QNA_FILTER_OPTIONS, QNA_DOMAIN, QNA_VENDOR_TYPE } from "@/lib/qna/validation" +import { DeleteQnaDialog } from "./delete-qna-dialog" +import { CreateQnaDialog } from "./create-qna-dialog" +import { UpdateQnaSheet } from "./update-qna-sheet" +import { QnaTableToolbarActions } from "./qna-table-toolbar-actions" +import { getColumns } from "./qna-table-columns" +import { getQnaList } from "../service" +import { useRouter } from "next/navigation" +import { getDomainIcon, getQnaStatusIcon, getVendorTypeIcon } from "./utils" +import { useSession } from "next-auth/react"; + +interface QnaTableProps { + promises: Promise< + [ + Awaited<ReturnType<typeof getQnaList>>, + // 추가 통계 데이터가 필요하면 여기에 추가 + ] + > + domain:string +} + +export function QnaTable({ promises, domain }: QnaTableProps) { + const [{ data, pageCount }] = React.use(promises) + const router = useRouter() + const { data: session } = useSession(); + + console.log(data) + + const [rowAction, setRowAction] = + React.useState<DataTableRowAction<QnaViewSelect> | null>(null) + + const [isCreateDialogOpen, setIsCreateDialogOpen] = React.useState(false) + + const columns = React.useMemo( + () => + getColumns({ + setRowAction, + router, + currentUserId: session?.user?.id, // 여기서 전달 + }), + [router, setRowAction, session?.user?.id] + ); + // 기본 필터 필드들 (상단 검색/필터 바용) + const filterFields: DataTableFilterField<QnaViewSelect>[] = [ + { + id: "title", + label: "제목", + placeholder: "제목으로 검색...", + }, + { + id: "authorName", + label: "작성자", + placeholder: "작성자명으로 검색...", + }, + { + id: "companyName", + label: "회사명", + placeholder: "회사명으로 검색...", + }, + { + id: "authorDomain", + label: "도메인", + options: QNA_FILTER_OPTIONS.authorDomain.map((domain) => ({ + label: domain.label, + value: domain.value, + icon: getDomainIcon(domain.value), + })), + }, + { + id: "vendorType", + label: "벤더 타입", + options: QNA_FILTER_OPTIONS.vendorType.map((type) => ({ + label: type.label, + value: type.value, + icon: getVendorTypeIcon(type.value), + })), + }, + { + id: "hasAnswers", + label: "답변 상태", + options: QNA_FILTER_OPTIONS.hasAnswers.map((status) => ({ + label: status.label, + value: status.value, + icon: getQnaStatusIcon(status.value), + })), + }, + ] + + // 고급 필터 필드들 (고급 검색용) + const advancedFilterFields: DataTableAdvancedFilterField<QnaViewSelect>[] = [ + { + id: "title", + label: "제목", + type: "text", + }, + { + id: "content", + label: "내용", + type: "text", + }, + { + id: "authorName", + label: "작성자명", + type: "text", + }, + { + id: "companyName", + label: "회사명", + type: "text", + }, + { + id: "authorDomain", + label: "사용자 도메인", + type: "multi-select", + options: QNA_FILTER_OPTIONS.authorDomain.map((domain) => ({ + label: domain.label, + value: domain.value, + icon: getDomainIcon(domain.value), + })), + }, + { + id: "vendorType", + label: "벤더 타입", + type: "multi-select", + options: QNA_FILTER_OPTIONS.vendorType.map((type) => ({ + label: type.label, + value: type.value, + icon: getVendorTypeIcon(type.value), + })), + }, + { + id: "hasAnswers", + label: "답변 여부", + type: "boolean", + }, + { + id: "isPopular", + label: "인기 질문", + type: "boolean", + }, + { + id: "totalAnswers", + label: "답변 수", + type: "number", + }, + { + id: "totalComments", + label: "댓글 수", + type: "number", + }, + { + id: "createdAt", + label: "작성일", + type: "date", + }, + { + id: "lastActivityAt", + label: "최근 활동일", + type: "date", + }, + ] + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "lastActivityAt", desc: true }], // 최근 활동순으로 기본 정렬 + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => originalRow.id.toString(), + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + <DataTable + table={table} + > + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <QnaTableToolbarActions + table={table} + domain={domain} + onCreateClick={() => setIsCreateDialogOpen(true)} + /> + </DataTableAdvancedToolbar> + </DataTable> + + {/* 질문 생성 다이얼로그 */} + <CreateQnaDialog + open={isCreateDialogOpen} + onOpenChange={setIsCreateDialogOpen} + /> + + {/* 질문 수정 시트 */} + <UpdateQnaSheet + open={rowAction?.type === "update"} + onOpenChange={() => setRowAction(null)} + qna={rowAction?.row.original ?? null} + /> + + {/* 질문 삭제 다이얼로그 */} + <DeleteQnaDialog + open={rowAction?.type === "delete"} + onOpenChange={() => setRowAction(null)} + qnas={rowAction?.row.original ? [rowAction?.row.original] : []} + showTrigger={false} + onSuccess={() => rowAction?.row.toggleSelected(false)} + /> + </> + ) +}
\ No newline at end of file diff --git a/lib/qna/table/update-qna-sheet.tsx b/lib/qna/table/update-qna-sheet.tsx new file mode 100644 index 00000000..72a3c633 --- /dev/null +++ b/lib/qna/table/update-qna-sheet.tsx @@ -0,0 +1,206 @@ +"use client" + +import * as React from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm } from "react-hook-form" +import { toast } from "sonner" + +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { ScrollArea } from "@/components/ui/scroll-area" +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" + +import { updateQnaSchema, type UpdateQnaSchema } from "@/lib/qna/validation" +import { QNA_CATEGORY_LABELS, QnaViewSelect } from "@/db/schema" +import { updateQna } from "../service" +import TiptapEditor from "@/components/qna/tiptap-editor" + +interface UpdateQnaSheetProps { + open: boolean + onOpenChange: (open: boolean) => void + qna: QnaViewSelect | null +} + +export function UpdateQnaSheet({ open, onOpenChange, qna }: UpdateQnaSheetProps) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + + const form = useForm<UpdateQnaSchema>({ + resolver: zodResolver(updateQnaSchema), + defaultValues: { + title: "", + content: "", + category: undefined, + + }, + }) + + // qna 데이터가 변경될 때 폼 초기화 + React.useEffect(() => { + if (qna) { + form.reset({ + title: qna.title, + content: qna.content, + category: qna.category, + }) + } + }, [qna, form]) + + + function onSubmit(input: UpdateQnaSchema) { + if (!qna) return + + startUpdateTransition(async () => { + try { + const result = await updateQna(qna.id, input) + + if (result.success) { + toast.success(result.message || "질문이 성공적으로 수정되었습니다.") + onOpenChange(false) + } else { + toast.error(result.error || "질문 수정에 실패했습니다.") + } + } catch (error) { + toast.error("예기치 못한 오류가 발생했습니다.") + console.error("질문 수정 오류:", error) + } + }) + } + + return ( + <Sheet open={open} onOpenChange={onOpenChange}> +<SheetContent className="flex h-full w-full flex-col gap-6 sm:max-w-4xl"> +<SheetHeader className="text-left"> + <SheetTitle>질문 수정</SheetTitle> + <SheetDescription> + 질문의 제목과 내용을 수정할 수 있습니다. 수정된 내용은 즉시 반영됩니다. + </SheetDescription> + </SheetHeader> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col flex-1 min-h-0 space-y-6"> + {/* 작성자 정보 표시 */} + {qna && ( + <div className="rounded-md border border-muted bg-muted/50 p-3 text-sm"> + <div className="font-medium text-muted-foreground mb-1">질문 정보</div> + <div className="space-y-1"> + <div>작성자: {qna.authorName} ({qna.authorEmail})</div> + <div>회사: {qna.companyName || "미지정"}</div> + <div>작성일: {new Date(qna.createdAt).toLocaleDateString("ko-KR")}</div> + <div>답변 수: {qna.totalAnswers}개 / 댓글 수: {qna.totalComments}개</div> + </div> + </div> + )} + + {/* 제목 입력 */} + <FormField + control={form.control} + name="title" + render={({ field }) => ( + <FormItem> + <FormLabel>제목 *</FormLabel> + <FormControl> + <Input + placeholder="질문의 제목을 입력해주세요" + disabled={isUpdatePending} + className="text-base" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="category" + render={({ field }) => ( + <FormItem> + <FormLabel>카테고리 *</FormLabel> + <Select + onValueChange={field.onChange} + defaultValue={field.value} + disabled={isUpdatePending} + > + <FormControl> + <SelectTrigger> + <SelectValue placeholder="카테고리를 선택해주세요" /> + </SelectTrigger> + </FormControl> + <SelectContent> + {QNA_CATEGORY_LABELS.map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + {/* 내용 입력 (리치텍스트 에디터) */} + <FormField + control={form.control} + name="content" + render={({ field }) => ( + <FormItem className="flex flex-col flex-1 min-h-0"> + <FormLabel>내용 *</FormLabel> + <FormControl className="flex flex-col flex-1 min-h-0"> + <TiptapEditor + content={field.value} + setContent={field.onChange} + disabled={isUpdatePending} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </form> + </Form> + + <SheetFooter className="gap-2 pt-4 border-t"> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isUpdatePending} + > + 취소 + </Button> + <Button + type="submit" + onClick={form.handleSubmit(onSubmit)} + disabled={isUpdatePending} + > + {isUpdatePending ? "수정 중..." : "수정 완료"} + </Button> + </SheetFooter> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/lib/qna/table/utils.tsx b/lib/qna/table/utils.tsx new file mode 100644 index 00000000..f7f9effe --- /dev/null +++ b/lib/qna/table/utils.tsx @@ -0,0 +1,329 @@ +import { + CheckCircle2, + AlertCircle, + TrendingUp, + Building2, + Wrench, + Shield, + User, + Users, + Crown, + MessageSquare, + MessageCircle, + Clock, + Calendar, + Eye, + EyeOff, + } from "lucide-react" + + /** + * Q&A 상태에 따른 아이콘 반환 + */ + export function getQnaStatusIcon(status: string) { + switch (status) { + case "answered": + return CheckCircle2 + case "unanswered": + return AlertCircle + case "popular": + return TrendingUp + default: + return MessageSquare + } + } + + /** + * 벤더 타입에 따른 아이콘 반환 + */ + export function getVendorTypeIcon(vendorType: string) { + switch (vendorType) { + case "vendor": + return Building2 + case "techVendor": + return Wrench + default: + return Building2 + } + } + + /** + * 사용자 도메인에 따른 아이콘 반환 + */ + export function getDomainIcon(domain: string) { + switch (domain) { + case "partners": + return Users + case "tech": + return Wrench + case "admin": + return Shield + default: + return User + } + } + + /** + * Q&A 상태에 따른 배지 색상 반환 + */ + export function getQnaStatusBadge(qna: { + hasAnswers: boolean + isPopular: boolean + totalAnswers: number + totalComments: number + }) { + const badges = [] + + if (qna.hasAnswers) { + badges.push({ + label: "답변됨", + variant: "secondary" as const, + icon: CheckCircle2, + }) + } else { + badges.push({ + label: "답변 대기", + variant: "outline" as const, + icon: AlertCircle, + }) + } + + if (qna.isPopular) { + badges.push({ + label: "인기", + variant: "default" as const, + icon: TrendingUp, + }) + } + + return badges + } + + /** + * 도메인에 따른 사용자 라벨 반환 + */ + export function getDomainLabel(domain: string) { + switch (domain) { + case "partners": + return "협력업체" + case "tech": + return "기술업체" + case "admin": + return "관리자" + default: + return domain + } + } + + /** + * 벤더 타입에 따른 라벨 반환 + */ + export function getVendorTypeLabel(vendorType: string) { + switch (vendorType) { + case "vendor": + return "일반 벤더" + case "techVendor": + return "기술 벤더" + default: + return vendorType + } + } + + /** + * Q&A 활동 통계 포맷팅 + */ + export function formatQnaStats(qna: { + totalAnswers: number + totalComments: number + lastActivityAt?: Date | null + }) { + const stats = [] + + if (qna.totalAnswers > 0) { + stats.push(`답변 ${qna.totalAnswers}개`) + } + + if (qna.totalComments > 0) { + stats.push(`댓글 ${qna.totalComments}개`) + } + + if (stats.length === 0) { + return "활동 없음" + } + + return stats.join(" • ") + } + + /** + * 상대적 시간 포맷팅 (예: "3시간 전", "2일 전") + */ + export function formatRelativeTime(date: Date | string) { + const now = new Date() + const targetDate = new Date(date) + const diffInMilliseconds = now.getTime() - targetDate.getTime() + const diffInSeconds = Math.floor(diffInMilliseconds / 1000) + const diffInMinutes = Math.floor(diffInSeconds / 60) + const diffInHours = Math.floor(diffInMinutes / 60) + const diffInDays = Math.floor(diffInHours / 24) + const diffInMonths = Math.floor(diffInDays / 30) + const diffInYears = Math.floor(diffInDays / 365) + + if (diffInSeconds < 60) { + return "방금 전" + } else if (diffInMinutes < 60) { + return `${diffInMinutes}분 전` + } else if (diffInHours < 24) { + return `${diffInHours}시간 전` + } else if (diffInDays < 30) { + return `${diffInDays}일 전` + } else if (diffInMonths < 12) { + return `${diffInMonths}개월 전` + } else { + return `${diffInYears}년 전` + } + } + + /** + * Q&A 우선순위 계산 (정렬용) + */ + export function calculateQnaPriority(qna: { + hasAnswers: boolean + isPopular: boolean + totalAnswers: number + totalComments: number + lastActivityAt?: Date | null + createdAt: Date + }) { + let priority = 0 + + // 답변이 없는 질문에 높은 우선순위 + if (!qna.hasAnswers) { + priority += 100 + } + + // 인기 질문에 우선순위 추가 + if (qna.isPopular) { + priority += 50 + } + + // 최근 활동에 따른 우선순위 + if (qna.lastActivityAt) { + const daysSinceActivity = Math.floor( + (new Date().getTime() - new Date(qna.lastActivityAt).getTime()) / (1000 * 60 * 60 * 24) + ) + priority += Math.max(0, 30 - daysSinceActivity) // 최근 30일 내 활동 + } + + // 활동량에 따른 우선순위 + priority += Math.min(qna.totalAnswers * 2, 20) // 답변 수 (최대 20점) + priority += Math.min(qna.totalComments, 10) // 댓글 수 (최대 10점) + + return priority + } + + /** + * Q&A 텍스트 요약 (미리보기용) + */ + export function truncateQnaContent(content: string, maxLength: number = 100) { + // HTML 태그 제거 + const textContent = content.replace(/<[^>]*>/g, "").trim() + + if (textContent.length <= maxLength) { + return textContent + } + + return textContent.slice(0, maxLength).trim() + "..." + } + + /** + * Q&A 검색 키워드 하이라이팅 + */ + export function highlightSearchKeywords(text: string, keywords: string) { + if (!keywords.trim()) return text + + const keywordList = keywords.trim().split(/\s+/) + let highlightedText = text + + keywordList.forEach(keyword => { + const regex = new RegExp(`(${keyword})`, "gi") + highlightedText = highlightedText.replace( + regex, + '<mark class="bg-yellow-200 dark:bg-yellow-800">$1</mark>' + ) + }) + + return highlightedText + } + + /** + * Q&A 필터 조건 검증 + */ + export function validateQnaFilters(filters: { + search?: string + authorDomain?: string[] + vendorType?: string[] + hasAnswers?: string + dateRange?: { from?: Date; to?: Date } + }) { + const errors: string[] = [] + + // 검색어 길이 체크 + if (filters.search && filters.search.length > 100) { + errors.push("검색어는 100자 이하로 입력해주세요.") + } + + // 날짜 범위 체크 + if (filters.dateRange?.from && filters.dateRange?.to) { + if (filters.dateRange.from > filters.dateRange.to) { + errors.push("시작 날짜가 종료 날짜보다 늦을 수 없습니다.") + } + } + + return { + isValid: errors.length === 0, + errors, + } + } + + /** + * Q&A URL 생성 유틸리티 + */ + export function generateQnaUrls(qnaId: number) { + const baseUrl = typeof window !== "undefined" ? window.location.origin : "" + + return { + detail: `${baseUrl}/qna/${qnaId}`, + edit: `${baseUrl}/qna/${qnaId}/edit`, + share: `${baseUrl}/qna/${qnaId}?shared=true`, + } + } + + /** + * Q&A 메타데이터 생성 (SEO용) + */ + export function generateQnaMetadata(qna: { + title: string + content: string + authorName: string + companyName?: string | null + totalAnswers: number + createdAt: Date + }) { + const description = truncateQnaContent(qna.content, 160) + const keywords = [ + "Q&A", + "질문", + "답변", + qna.authorName, + qna.companyName, + ...qna.title.split(" ").slice(0, 5), // 제목의 첫 5단어 + ].filter(Boolean).join(", ") + + return { + title: `${qna.title} - Q&A`, + description, + keywords, + author: qna.authorName, + publishedTime: qna.createdAt.toISOString(), + articleTag: ["Q&A", "질문", "답변"], + } + }
\ No newline at end of file diff --git a/lib/qna/validation.ts b/lib/qna/validation.ts new file mode 100644 index 00000000..dd140963 --- /dev/null +++ b/lib/qna/validation.ts @@ -0,0 +1,374 @@ +import { qnaView, type QnaViewSelect } from "@/db/schema"; +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, +} from "nuqs/server"; +import * as z from "zod"; + +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"; + +// Q&A 검색 파라미터 캐시 +export const searchParamsQnaCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser<QnaViewSelect>().withDefault([ + { id: "lastActivityAt", desc: true }, // Q&A는 최근 활동순이 기본 + ]), + + // 고급 필터 + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + + // 전역 검색 + search: parseAsString.withDefault(""), + + // Q&A 특화 필터 + authorDomain: parseAsArrayOf( + z.enum(["partners", "tech", "admin"]) + ).withDefault([]), + + vendorType: parseAsArrayOf( + z.enum(["vendor", "techVendor"]) + ).withDefault([]), + + hasAnswers: parseAsStringEnum([ + "all", + "answered", + "unanswered" + ]).withDefault("all"), + + myQuestions: parseAsStringEnum([ + "all", + "mine", + "others" + ]).withDefault("all"), + + isPopular: parseAsStringEnum([ + "all", + "popular", + "normal" + ]).withDefault("all"), + + // 날짜 범위 필터 + from: parseAsString.withDefault(""), + to: parseAsString.withDefault(""), + + // 답변 수 범위 필터 + minAnswers: parseAsInteger.withDefault(0), + maxAnswers: parseAsInteger.withDefault(999), + + // 회사별 필터 (특정 회사 선택) + companyNames: parseAsArrayOf(z.string()).withDefault([]), +}); + +// Q&A 생성 스키마 +export const createQnaSchema = z.object({ + title: z + .string() + .min(1, "제목을 입력해주세요") + .max(255, "제목은 255자 이하로 입력해주세요") + .trim(), + content: z + .string() + .min(1, "내용을 입력해주세요") + // .max(10000, "내용은 10000자 이하로 입력해주세요") + .trim(), + category: z + .enum(['engineering', 'procurement', 'technical_sales'], { + required_error: "카테고리를 선택해주세요", + invalid_type_error: "올바른 카테고리를 선택해주세요" + }) + }); +// Q&A 수정 스키마 +export const updateQnaSchema = z.object({ + title: z + .string() + .min(1, "제목을 입력해주세요") + .max(255, "제목은 255자 이하로 입력해주세요") + .trim() + .optional(), + content: z + .string() + .min(1, "내용을 입력해주세요") + // .max(10000, "내용은 10000자 이하로 입력해주세요") + .trim() + .optional(), + category: z + .enum(['engineering', 'procurement', 'technical_sales'], { + required_error: "카테고리를 선택해주세요", + invalid_type_error: "올바른 카테고리를 선택해주세요" + }) + .optional(), +}); + +// 답변 생성 스키마 +export const createAnswerSchema = z.object({ + qnaId: z + .number() + .positive("유효하지 않은 질문 ID입니다"), + content: z + .string() + .min(1, "답변 내용을 입력해주세요") + .max(10000, "답변은 10000자 이하로 입력해주세요") + .trim(), +}); + +// 답변 수정 스키마 +export const updateAnswerSchema = z.object({ + content: z + .string() + .min(1, "답변 내용을 입력해주세요") + .max(10000, "답변은 10000자 이하로 입력해주세요") + .trim(), +}); + +// 댓글 생성 스키마 +export const createCommentSchema = z.object({ + answerId: z + .number() + .positive("유효하지 않은 답변 ID입니다"), + content: z + .string() + .min(1, "댓글 내용을 입력해주세요") + .max(1000, "댓글은 1000자 이하로 입력해주세요") + .trim(), + parentCommentId: z + .number() + .positive() + .optional(), +}); + +// 댓글 수정 스키마 +export const updateCommentSchema = z.object({ + content: z + .string() + .min(1, "댓글 내용을 입력해주세요") + .max(1000, "댓글은 1000자 이하로 입력해주세요") + .trim(), +}); + +// Q&A 벌크 액션 스키마 +export const bulkQnaActionSchema = z.object({ + qnaIds: z + .array(z.number().positive()) + .min(1, "선택된 질문이 없습니다"), + action: z.enum([ + "delete", + "restore", + "archive" + ]), +}); + +// Q&A 검색 필터 스키마 (고급 검색용) +export const qnaSearchFilterSchema = z.object({ + title: z.string().optional(), + content: z.string().optional(), + authorName: z.string().optional(), + companyName: z.string().optional(), + authorDomain: z.array(z.enum(["partners", "tech", "admin"])).optional(), + vendorType: z.array(z.enum(["vendor", "techVendor"])).optional(), + hasAnswers: z.enum(["all", "answered", "unanswered"]).optional(), + isPopular: z.boolean().optional(), + createdFrom: z.date().optional(), + createdTo: z.date().optional(), + lastActivityFrom: z.date().optional(), + lastActivityTo: z.date().optional(), + minAnswers: z.number().min(0).optional(), + maxAnswers: z.number().min(0).optional(), + minComments: z.number().min(0).optional(), + maxComments: z.number().min(0).optional(), +}); + +// Q&A 정렬 옵션 스키마 +export const qnaSortSchema = z.object({ + field: z.enum([ + "createdAt", + "updatedAt", + "lastActivityAt", + "title", + "authorName", + "companyName", + "totalAnswers", + "totalComments", + "answerCount", + "isPopular" + ]), + direction: z.enum(["asc", "desc"]), +}); + +// Q&A 내보내기 스키마 +export const exportQnaSchema = z.object({ + format: z.enum(["csv", "excel", "pdf"]), + fields: z.array(z.enum([ + "title", + "content", + "authorName", + "companyName", + "createdAt", + "totalAnswers", + "totalComments", + "lastActivityAt" + ])).default([ + "title", + "authorName", + "companyName", + "createdAt", + "totalAnswers" + ]), + dateRange: z.object({ + from: z.date().optional(), + to: z.date().optional(), + }).optional(), + includeAnswers: z.boolean().default(false), + includeComments: z.boolean().default(false), +}); + +// 타입 내보내기 +export type GetQnaSchema = Awaited<ReturnType<typeof searchParamsQnaCache.parse>>; +export type CreateQnaSchema = z.infer<typeof createQnaSchema>; +export type UpdateQnaSchema = z.infer<typeof updateQnaSchema>; +export type CreateAnswerSchema = z.infer<typeof createAnswerSchema>; +export type UpdateAnswerSchema = z.infer<typeof updateAnswerSchema>; +export type CreateCommentSchema = z.infer<typeof createCommentSchema>; +export type UpdateCommentSchema = z.infer<typeof updateCommentSchema>; +export type BulkQnaActionSchema = z.infer<typeof bulkQnaActionSchema>; +export type QnaSearchFilterSchema = z.infer<typeof qnaSearchFilterSchema>; +export type QnaSortSchema = z.infer<typeof qnaSortSchema>; +export type ExportQnaSchema = z.infer<typeof exportQnaSchema>; + +// Q&A 상태 enum (프론트엔드에서 사용) +export const QNA_STATUS = { + UNANSWERED: "unanswered", + ANSWERED: "answered", + POPULAR: "popular", + ARCHIVED: "archived", +} as const; + +export const QNA_DOMAIN = { + PARTNERS: "partners", + TECH: "tech", + ADMIN: "admin", +} as const; + +export const QNA_VENDOR_TYPE = { + VENDOR: "vendor", + TECH_VENDOR: "techVendor", +} as const; + +// 필터 옵션 (프론트엔드 드롭다운용) +export const QNA_FILTER_OPTIONS = { + hasAnswers: [ + { value: "all", label: "전체" }, + { value: "answered", label: "답변있음" }, + { value: "unanswered", label: "답변없음" }, + ], + authorDomain: [ + { value: "partners", label: "협력업체" }, + { value: "tech", label: "기술업체" }, + { value: "admin", label: "관리자" }, + ], + vendorType: [ + { value: "vendor", label: "일반 벤더" }, + { value: "techVendor", label: "기술 벤더" }, + ], + myQuestions: [ + { value: "all", label: "전체 질문" }, + { value: "mine", label: "내 질문" }, + { value: "others", label: "다른 사람 질문" }, + ], + isPopular: [ + { value: "all", label: "전체" }, + { value: "popular", label: "인기 질문" }, + { value: "normal", label: "일반 질문" }, + ], +} as const; + +// 정렬 옵션 (프론트엔드 드롭다운용) +export const QNA_SORT_OPTIONS = [ + { value: "lastActivityAt", label: "최근 활동순", direction: "desc" }, + { value: "createdAt", label: "최신 등록순", direction: "desc" }, + { value: "createdAt", label: "오래된 순", direction: "asc" }, + { value: "title", label: "제목순", direction: "asc" }, + { value: "totalAnswers", label: "답변 많은 순", direction: "desc" }, + { value: "totalComments", label: "댓글 많은 순", direction: "desc" }, + { value: "authorName", label: "작성자순", direction: "asc" }, + { value: "companyName", label: "회사명순", direction: "asc" }, +] as const; + +// 유틸리티 함수들 +export const qnaValidationUtils = { + /** + * 검색 파라미터 유효성 검사 + */ + validateSearchParams: (params: unknown) => { + try { + return searchParamsQnaCache.parse(params); + } catch (error) { + console.error("Invalid search parameters:", error); + return searchParamsQnaCache.parse({}); // 기본값 반환 + } + }, + + /** + * 날짜 범위 유효성 검사 + */ + validateDateRange: (from?: string, to?: string) => { + if (!from && !to) return { isValid: true }; + + const fromDate = from ? new Date(from) : null; + const toDate = to ? new Date(to) : null; + + if (fromDate && toDate && fromDate > toDate) { + return { + isValid: false, + error: "시작 날짜가 종료 날짜보다 늦을 수 없습니다." + }; + } + + return { isValid: true }; + }, + + /** + * 페이지네이션 범위 계산 + */ + calculatePagination: (page: number, perPage: number, total: number) => { + const totalPages = Math.ceil(total / perPage); + const hasNextPage = page < totalPages; + const hasPrevPage = page > 1; + const startIndex = (page - 1) * perPage + 1; + const endIndex = Math.min(page * perPage, total); + + return { + totalPages, + hasNextPage, + hasPrevPage, + startIndex, + endIndex, + isFirstPage: page === 1, + isLastPage: page === totalPages, + }; + }, + + /** + * 필터 활성 상태 확인 + */ + hasActiveFilters: (params: GetQnaSchema) => { + return !!( + params.search || + params.authorDomain.length > 0 || + params.vendorType.length > 0 || + params.hasAnswers !== "all" || + params.myQuestions !== "all" || + params.isPopular !== "all" || + params.from || + params.to || + params.minAnswers > 0 || + params.maxAnswers < 999 || + params.companyNames.length > 0 + ); + }, +};
\ No newline at end of file diff --git a/lib/users/access-control/users-table.tsx b/lib/users/access-control/users-table.tsx index 50ce4dee..e71d11e6 100644 --- a/lib/users/access-control/users-table.tsx +++ b/lib/users/access-control/users-table.tsx @@ -78,7 +78,7 @@ export function UserAccessControlTable({ promises }: UsersTableProps) { { label: "🟢 구매관리팀", value: "procurement" }, { label: "🟣 기술영업팀", value: "sales" }, { label: "🟠 설계관리팀", value: "engineering" }, - { label: "🟦 협력업체", value: "partners" }, + // { label: "🟦 협력업체", value: "partners" }, ], }, { |
