summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/b-rfq/service.ts6
-rw-r--r--lib/b-rfq/summary-table/summary-rfq-columns.tsx4
-rw-r--r--lib/dashboard/dashboard-client.tsx115
-rw-r--r--lib/dashboard/dashboard-overview-chart.tsx325
-rw-r--r--lib/dashboard/dashboard-stats-card.tsx88
-rw-r--r--lib/dashboard/dashboard-summary-cards.tsx64
-rw-r--r--lib/dashboard/partners-service.ts447
-rw-r--r--lib/dashboard/service.ts454
-rw-r--r--lib/qna/service.ts1006
-rw-r--r--lib/qna/table/create-qna-dialog.tsx203
-rw-r--r--lib/qna/table/delete-qna-dialog.tsx250
-rw-r--r--lib/qna/table/improved-comment-section.tsx319
-rw-r--r--lib/qna/table/qna-detail.tsx455
-rw-r--r--lib/qna/table/qna-export-actions.tsx261
-rw-r--r--lib/qna/table/qna-table-columns.tsx325
-rw-r--r--lib/qna/table/qna-table-toolbar-actions.tsx176
-rw-r--r--lib/qna/table/qna-table.tsx236
-rw-r--r--lib/qna/table/update-qna-sheet.tsx206
-rw-r--r--lib/qna/table/utils.tsx329
-rw-r--r--lib/qna/validation.ts374
-rw-r--r--lib/users/access-control/users-table.tsx2
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" },
],
},
{