Merge branch 'main' of https://g.wace.me/jskim/vexplor_dev
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiClient, API_BASE_URL } from "@/lib/api/client";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
|
||||
// ============================================
|
||||
// Types
|
||||
// ============================================
|
||||
export interface Room {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "dm" | "group" | "channel";
|
||||
lastMessage?: string;
|
||||
lastMessageAt?: string;
|
||||
unreadCount: number;
|
||||
participants: Participant[];
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface Participant {
|
||||
userId: string;
|
||||
userName: string;
|
||||
photo?: string | null;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
roomId: string;
|
||||
senderId: string;
|
||||
senderName: string;
|
||||
senderPhoto?: string | null;
|
||||
content: string;
|
||||
type: "text" | "file" | "system";
|
||||
fileUrl?: string;
|
||||
fileName?: string;
|
||||
fileMimeType?: string | null;
|
||||
reactions: Reaction[];
|
||||
threadCount?: number;
|
||||
parentId?: string | null;
|
||||
isDeleted: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Reaction {
|
||||
emoji: string;
|
||||
users: { userId: string; userName: string }[];
|
||||
}
|
||||
|
||||
export interface CompanyUser {
|
||||
userId: string;
|
||||
userName: string;
|
||||
deptName?: string;
|
||||
positionName?: string;
|
||||
photo?: string | null;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// API helpers
|
||||
// ============================================
|
||||
async function fetchApi<T>(url: string): Promise<T> {
|
||||
const res = await apiClient.get(url);
|
||||
return res.data?.data ?? res.data;
|
||||
}
|
||||
|
||||
async function postApi<T>(url: string, data?: unknown): Promise<T> {
|
||||
const res = await apiClient.post(url, data);
|
||||
return res.data?.data ?? res.data;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Hooks
|
||||
// ============================================
|
||||
export function useRooms() {
|
||||
const { user } = useAuth();
|
||||
const companyCode = user?.companyCode || user?.company_code;
|
||||
return useQuery<Room[]>({
|
||||
queryKey: ["messenger", "rooms", companyCode],
|
||||
queryFn: async () => {
|
||||
const data = await fetchApi<any[]>("/messenger/rooms");
|
||||
return data.map((r) => ({
|
||||
id: String(r.id),
|
||||
name: r.room_name ?? r.name ?? "",
|
||||
type: r.room_type ?? r.type,
|
||||
lastMessage: r.last_message ?? r.lastMessage,
|
||||
lastMessageAt: r.last_message_at ?? r.lastMessageAt,
|
||||
unreadCount: r.unread_count ?? r.unreadCount ?? 0,
|
||||
description: r.description,
|
||||
participants: (r.participants ?? []).map((p: any) => ({
|
||||
userId: p.user_id ?? p.userId,
|
||||
userName: p.user_name ?? p.userName,
|
||||
photo: p.photo ? `data:image/jpeg;base64,${p.photo}` : null,
|
||||
})),
|
||||
}));
|
||||
},
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMessages(roomId: string | null) {
|
||||
return useQuery<Message[]>({
|
||||
queryKey: ["messenger", "messages", roomId],
|
||||
queryFn: async () => {
|
||||
const data = await fetchApi<any[]>(`/messenger/rooms/${roomId}/messages`);
|
||||
return data.map((m) => ({
|
||||
id: String(m.id),
|
||||
roomId: String(m.room_id ?? m.roomId),
|
||||
senderId: m.sender_id ?? m.senderId,
|
||||
senderName: m.sender_name ?? m.senderName ?? m.sender_id,
|
||||
senderPhoto: m.sender_photo
|
||||
? `data:image/jpeg;base64,${m.sender_photo}`
|
||||
: (m.senderPhoto ?? null),
|
||||
content: m.content ?? "",
|
||||
type: m.message_type ?? m.type ?? "text",
|
||||
fileUrl: m.files?.[0]?.id
|
||||
? `${API_BASE_URL}/messenger/files/${m.files[0].id}`
|
||||
: (m.file_url ?? m.fileUrl),
|
||||
fileName: m.files?.[0]?.original_name ?? m.file_name ?? m.fileName,
|
||||
fileMimeType: m.files?.[0]?.mime_type ?? null,
|
||||
reactions: m.reactions ?? [],
|
||||
threadCount: m.thread_count ?? m.threadCount ?? 0,
|
||||
parentId: m.parent_message_id ?? m.parentId ?? null,
|
||||
isDeleted: m.is_deleted ?? m.isDeleted ?? false,
|
||||
createdAt: m.created_at ?? m.createdAt,
|
||||
}));
|
||||
},
|
||||
enabled: !!roomId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCompanyUsers() {
|
||||
const { user } = useAuth();
|
||||
const companyCode = user?.companyCode || user?.company_code;
|
||||
return useQuery<CompanyUser[]>({
|
||||
queryKey: ["messenger", "users", companyCode],
|
||||
queryFn: async () => {
|
||||
const data = await fetchApi<any[]>("/messenger/users");
|
||||
return data.map((u) => ({
|
||||
userId: u.user_id ?? u.userId,
|
||||
userName: u.user_name ?? u.userName,
|
||||
deptName: u.dept_name ?? u.deptName,
|
||||
photo: u.photo ? `data:image/jpeg;base64,${u.photo}` : null,
|
||||
}));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUnreadCount() {
|
||||
return useQuery<number>({
|
||||
queryKey: ["messenger", "unread"],
|
||||
queryFn: () => fetchApi("/messenger/unread"),
|
||||
refetchInterval: 15000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSendMessage() {
|
||||
const qc = useQueryClient();
|
||||
const { user } = useAuth();
|
||||
return useMutation({
|
||||
mutationFn: (payload: { roomId: string; content: string; type?: string; parentId?: string | null; fileUrl?: string; fileName?: string }) =>
|
||||
postApi(`/messenger/rooms/${payload.roomId}/messages`, {
|
||||
content: payload.content,
|
||||
type: payload.type,
|
||||
parentId: payload.parentId,
|
||||
file_url: payload.fileUrl,
|
||||
file_name: payload.fileName,
|
||||
}),
|
||||
onMutate: async (variables) => {
|
||||
const queryKey = ["messenger", "messages", variables.roomId];
|
||||
await qc.cancelQueries({ queryKey });
|
||||
const previous = qc.getQueryData<Message[]>(queryKey);
|
||||
const optimistic: Message = {
|
||||
id: `optimistic-${Date.now()}`,
|
||||
roomId: variables.roomId,
|
||||
senderId: user?.userId ?? "me",
|
||||
senderName: "",
|
||||
content: variables.content,
|
||||
type: (variables.type as Message["type"]) ?? "text",
|
||||
reactions: [],
|
||||
isDeleted: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
qc.setQueryData<Message[]>(queryKey, (old) => [...(old ?? []), optimistic]);
|
||||
return { previous, queryKey };
|
||||
},
|
||||
onError: (_err, _vars, context) => {
|
||||
if (context) qc.setQueryData(context.queryKey, context.previous);
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
qc.invalidateQueries({ queryKey: ["messenger", "messages", variables.roomId] });
|
||||
qc.invalidateQueries({ queryKey: ["messenger", "rooms"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateRoom() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (payload: { type: "dm" | "group" | "channel"; name?: string; description?: string; participantIds: string[] }) =>
|
||||
postApi<Room>("/messenger/rooms", payload),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["messenger", "rooms"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useMarkAsRead() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (roomId: string) => postApi(`/messenger/rooms/${roomId}/read`),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["messenger", "unread"] });
|
||||
qc.invalidateQueries({ queryKey: ["messenger", "rooms"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAddReaction() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (payload: { messageId: string; roomId: string; emoji: string }) =>
|
||||
postApi(`/messenger/messages/${payload.messageId}/reactions`, { emoji: payload.emoji }),
|
||||
onSuccess: (_data, variables) => {
|
||||
qc.invalidateQueries({ queryKey: ["messenger", "messages", variables.roomId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateRoom() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ roomId, name }: { roomId: string; name: string }) =>
|
||||
apiClient.put(`/messenger/rooms/${roomId}`, { room_name: name }).then((res) => res.data?.data ?? res.data),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["messenger", "rooms"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUploadFile() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ file, roomId }: { file: File; roomId: string }) => {
|
||||
const formData = new FormData();
|
||||
formData.append("files", file);
|
||||
formData.append("room_id", roomId);
|
||||
const res = await apiClient.post("/messenger/files/upload", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
return res.data?.data ?? res.data;
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
qc.invalidateQueries({ queryKey: ["messenger", "messages", variables.roomId] });
|
||||
qc.invalidateQueries({ queryKey: ["messenger", "rooms"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useCallback, useState } from "react";
|
||||
import { io, Socket } from "socket.io-client";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useMessengerContext } from "@/contexts/MessengerContext";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || "http://localhost:8080";
|
||||
|
||||
export type UserStatus = "online" | "away" | "offline";
|
||||
|
||||
interface NewMessageEvent {
|
||||
room_id: number;
|
||||
sender_name: string;
|
||||
sender_id: string;
|
||||
sender_photo?: string | null;
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface TypingEvent {
|
||||
room_id: number;
|
||||
user_id: string;
|
||||
user_name: string;
|
||||
}
|
||||
|
||||
export function useMessengerSocket() {
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
const { selectedRoomId, notificationEnabled, isOpen, openMessenger, mutedRooms } = useMessengerContext();
|
||||
const currentUserIdRef = useRef<string | null>(null);
|
||||
const selectedRoomIdRef = useRef(selectedRoomId);
|
||||
const notificationEnabledRef = useRef(notificationEnabled);
|
||||
const isOpenRef = useRef(isOpen);
|
||||
const mutedRoomsRef = useRef(mutedRooms);
|
||||
|
||||
const qc = useQueryClient();
|
||||
const [userStatuses, setUserStatuses] = useState<Map<string, UserStatus>>(new Map());
|
||||
const [typingUsers, setTypingUsers] = useState<Map<string, string[]>>(new Map());
|
||||
|
||||
// Keep refs in sync so socket handlers use latest values
|
||||
useEffect(() => {
|
||||
selectedRoomIdRef.current = selectedRoomId;
|
||||
}, [selectedRoomId]);
|
||||
|
||||
useEffect(() => {
|
||||
notificationEnabledRef.current = notificationEnabled;
|
||||
}, [notificationEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
isOpenRef.current = isOpen;
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
mutedRoomsRef.current = mutedRooms;
|
||||
}, [mutedRooms]);
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem("authToken");
|
||||
if (!token) return;
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split(".")[1]));
|
||||
currentUserIdRef.current = payload.userId ?? payload.user_id ?? null;
|
||||
} catch {}
|
||||
|
||||
const socket = io(BACKEND_URL, {
|
||||
path: "/socket.io",
|
||||
auth: { token },
|
||||
transports: ["websocket", "polling"],
|
||||
});
|
||||
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.on("connect", () => {
|
||||
socket.emit("join_rooms");
|
||||
});
|
||||
|
||||
// Receive full presence list on connect
|
||||
socket.on("presence_list", (data: Record<string, string>) => {
|
||||
setUserStatuses((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const [uid, status] of Object.entries(data)) {
|
||||
next.set(uid, status as UserStatus);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
});
|
||||
|
||||
// Receive individual status updates
|
||||
socket.on("user_status", (data: { userId: string; status: UserStatus }) => {
|
||||
setUserStatuses((prev) => {
|
||||
const next = new Map(prev);
|
||||
if (data.status === "offline") {
|
||||
next.delete(data.userId);
|
||||
} else {
|
||||
next.set(data.userId, data.status);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
});
|
||||
|
||||
// Tab visibility → away/online
|
||||
const handleVisibilityChange = () => {
|
||||
const status = document.hidden ? "away" : "online";
|
||||
socket.emit("set_status", { status });
|
||||
};
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
// BUG-5 & BUG-8: Handle new_message with cache invalidation and toast
|
||||
socket.on("new_message", (data: NewMessageEvent) => {
|
||||
const roomIdStr = String(data.room_id);
|
||||
|
||||
qc.invalidateQueries({ queryKey: ["messenger", "messages", roomIdStr] });
|
||||
qc.invalidateQueries({ queryKey: ["messenger", "rooms"] });
|
||||
qc.invalidateQueries({ queryKey: ["messenger", "unread"] });
|
||||
|
||||
const isOwnMessage = data.sender_id === currentUserIdRef.current;
|
||||
const isRoomMuted = mutedRoomsRef.current.has(roomIdStr);
|
||||
if (notificationEnabledRef.current && !isOwnMessage && !isRoomMuted && (!isOpenRef.current || roomIdStr !== selectedRoomIdRef.current)) {
|
||||
const photoSrc = data.sender_photo
|
||||
? `data:image/jpeg;base64,${data.sender_photo}`
|
||||
: null;
|
||||
toast(
|
||||
<div className="flex items-center gap-2 cursor-pointer" onClick={() => openMessenger(roomIdStr)}>
|
||||
{photoSrc ? (
|
||||
<img src={photoSrc} alt="" className="w-6 h-6 rounded-full object-cover shrink-0" />
|
||||
) : (
|
||||
<div className="w-6 h-6 rounded-full bg-muted flex items-center justify-center text-xs font-medium shrink-0">
|
||||
{(data.sender_name || "?").charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-sm">{data.sender_name || "새 메시지"}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">{data.content?.slice(0, 60) || ""}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// BUG-7: Backend emits "user_typing" / "user_stop_typing"
|
||||
socket.on("user_typing", (data: TypingEvent) => {
|
||||
const roomIdStr = String(data.room_id);
|
||||
setTypingUsers((prev) => {
|
||||
const next = new Map(prev);
|
||||
const users = next.get(roomIdStr) || [];
|
||||
if (!users.includes(data.user_name)) {
|
||||
next.set(roomIdStr, [...users, data.user_name]);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
});
|
||||
|
||||
socket.on("user_stop_typing", (data: TypingEvent) => {
|
||||
const roomIdStr = String(data.room_id);
|
||||
setTypingUsers((prev) => {
|
||||
const next = new Map(prev);
|
||||
const users = next.get(roomIdStr) || [];
|
||||
next.set(roomIdStr, users.filter((u) => u !== data.user_name));
|
||||
return next;
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
socket.disconnect();
|
||||
socketRef.current = null;
|
||||
};
|
||||
}, [toast, qc]);
|
||||
|
||||
const emitTypingStart = useCallback(
|
||||
(roomId: string) => {
|
||||
socketRef.current?.emit("typing_start", { room_id: Number(roomId) });
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const emitTypingStop = useCallback(
|
||||
(roomId: string) => {
|
||||
socketRef.current?.emit("typing_stop", { room_id: Number(roomId) });
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return { socket: socketRef, userStatuses, typingUsers, emitTypingStart, emitTypingStop };
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { useMultiLang } from "@/hooks/useMultiLang";
|
||||
import { setTranslationCache } from "@/lib/utils/multilang";
|
||||
|
||||
interface UsePageMultiLangOptions {
|
||||
keys: readonly string[];
|
||||
defaults: Record<string, string>;
|
||||
menuCode: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지별 다국어 텍스트 관리 훅
|
||||
* - keys: 다국어 키 배열
|
||||
* - defaults: 한국어 기본 텍스트 매핑
|
||||
* - menuCode: 배치 API에 전달할 메뉴 코드
|
||||
*/
|
||||
export function usePageMultiLang({ keys, defaults, menuCode }: UsePageMultiLangOptions) {
|
||||
const { userLang } = useMultiLang();
|
||||
const [uiTexts, setUiTexts] = useState<Record<string, string>>(() => ({ ...defaults }));
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 배치 번역 로드
|
||||
useEffect(() => {
|
||||
if (!userLang || loading) return;
|
||||
|
||||
let cancelled = false;
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await apiClient.post(
|
||||
"/multilang/batch",
|
||||
{
|
||||
langKeys: keys,
|
||||
companyCode: "*",
|
||||
menuCode,
|
||||
userLang,
|
||||
},
|
||||
{ params: {} },
|
||||
);
|
||||
|
||||
if (!cancelled && response.data.success && response.data.data) {
|
||||
const merged = { ...defaults, ...response.data.data };
|
||||
setUiTexts(merged);
|
||||
setTranslationCache(userLang, merged);
|
||||
}
|
||||
} catch {
|
||||
// API 실패 시 기본 텍스트 유지
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
load();
|
||||
return () => { cancelled = true; };
|
||||
}, [userLang]);
|
||||
|
||||
// 동기 텍스트 조회 함수
|
||||
const t = useCallback(
|
||||
(key: string, params?: Record<string, string | number>): string => {
|
||||
let text = uiTexts[key] || defaults[key] || key;
|
||||
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([k, v]) => {
|
||||
text = text.replace(`{${k}}`, String(v));
|
||||
});
|
||||
}
|
||||
|
||||
return text;
|
||||
},
|
||||
[uiTexts, defaults],
|
||||
);
|
||||
|
||||
return { t, userLang, loading };
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { reportApi } from "@/lib/api/reportApi";
|
||||
import { ReportDetail, ReportPage, ReportQuery, WatermarkConfig } from "@/types/report";
|
||||
|
||||
export interface QueryResult {
|
||||
queryId: string;
|
||||
fields: string[];
|
||||
rows: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 리포트 데이터 로딩 + 쿼리 실행 훅
|
||||
*
|
||||
* ReportListPreviewModal의 데이터 로딩 로직을 추출하여
|
||||
* 모달/인라인 어디서든 재사용 가능하도록 분리.
|
||||
*/
|
||||
export function useReportRenderer(
|
||||
reportId: string | null,
|
||||
contextParams?: Record<string, unknown>,
|
||||
) {
|
||||
const [detail, setDetail] = useState<ReportDetail | null>(null);
|
||||
const [queryResults, setQueryResults] = useState<QueryResult[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const getQueryResult = useCallback(
|
||||
(queryId: string): QueryResult | null => {
|
||||
return queryResults.find((r) => r.queryId === queryId) || null;
|
||||
},
|
||||
[queryResults],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!reportId) {
|
||||
setDetail(null);
|
||||
setQueryResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setIsLoading(true);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const res = await reportApi.getReportById(reportId);
|
||||
if (cancelled || !res.success) return;
|
||||
|
||||
setDetail(res.data);
|
||||
|
||||
// 쿼리 자동 실행
|
||||
const queries: ReportQuery[] = res.data.queries ?? [];
|
||||
if (queries.length === 0) return;
|
||||
|
||||
// contextParams에서 키 기반으로 매핑 ($1, $2 등 키를 우선 매칭)
|
||||
const buildParams = (parameters: string[]): Record<string, unknown> => {
|
||||
const result: Record<string, unknown> = {};
|
||||
parameters.forEach((param) => {
|
||||
result[param] = contextParams?.[param] ?? null;
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
const results: QueryResult[] = [];
|
||||
for (const q of queries) {
|
||||
try {
|
||||
const params = buildParams(q.parameters ?? []);
|
||||
const execRes = await reportApi.executeQuery(
|
||||
reportId,
|
||||
q.query_id,
|
||||
params,
|
||||
undefined, // sql_query를 보내지 않고 서버 저장 쿼리 사용
|
||||
q.external_connection_id,
|
||||
);
|
||||
if (execRes.success && execRes.data) {
|
||||
results.push({
|
||||
queryId: q.query_id,
|
||||
fields: execRes.data.fields,
|
||||
rows: execRes.data.rows,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// 개별 쿼리 실패는 무시
|
||||
}
|
||||
}
|
||||
|
||||
if (!cancelled) setQueryResults(results);
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setDetail(null);
|
||||
setQueryResults([]);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setIsLoading(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [reportId, JSON.stringify(contextParams)]);
|
||||
|
||||
const { pages, watermark } = useMemo(() => {
|
||||
const empty = { pages: [] as ReportPage[], watermark: undefined as WatermarkConfig | undefined };
|
||||
if (!detail?.layout) return empty;
|
||||
|
||||
const layout = detail.layout as unknown as Record<string, unknown>;
|
||||
|
||||
let config: Record<string, unknown> | null = null;
|
||||
|
||||
let raw: unknown = layout.components;
|
||||
|
||||
while (typeof raw === "string") {
|
||||
try {
|
||||
raw = JSON.parse(raw);
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
||||
config = raw as Record<string, unknown>;
|
||||
}
|
||||
|
||||
if (!config && Array.isArray(layout.pages)) {
|
||||
config = layout;
|
||||
}
|
||||
|
||||
if (!config) return empty;
|
||||
|
||||
const foundPages = Array.isArray(config.pages) ? (config.pages as ReportPage[]) : [];
|
||||
const foundWatermark = config.watermark as WatermarkConfig | undefined;
|
||||
|
||||
return { pages: foundPages, watermark: foundWatermark };
|
||||
}, [detail?.layout]);
|
||||
|
||||
return {
|
||||
detail,
|
||||
pages,
|
||||
watermark,
|
||||
queryResults,
|
||||
getQueryResult,
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* useTableSettings — 날코딩 페이지용 테이블 설정 훅
|
||||
*
|
||||
* TableSettingsModal과 함께 사용하여 컬럼 표시/숨김, 순서, 너비를 관리합니다.
|
||||
* 설정은 localStorage에 자동 저장/복원됩니다.
|
||||
*
|
||||
* @example
|
||||
* const ts = useTableSettings("item-info", TABLE_NAME, GRID_COLUMNS);
|
||||
*
|
||||
* // 툴바 버튼
|
||||
* <Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)}>
|
||||
* <Settings2 className="h-4 w-4" />
|
||||
* </Button>
|
||||
*
|
||||
* // 테이블 헤더 — GRID_COLUMNS 대신 ts.visibleColumns 사용
|
||||
* {ts.visibleColumns.map(col => <TableHead key={col.key}>{col.label}</TableHead>)}
|
||||
*
|
||||
* // 모달 (JSX 하단)
|
||||
* <TableSettingsModal
|
||||
* open={ts.open}
|
||||
* onOpenChange={ts.setOpen}
|
||||
* tableName={ts.tableName}
|
||||
* settingsId={ts.settingsId}
|
||||
* onSave={ts.applySettings}
|
||||
* />
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { loadTableSettings, type TableSettings } from "@/components/common/TableSettingsModal";
|
||||
|
||||
export function useTableSettings<T extends { key: string }>(
|
||||
settingsId: string,
|
||||
tableName: string,
|
||||
defaultColumns: T[],
|
||||
) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [visibleKeys, setVisibleKeys] = useState<Set<string>>(
|
||||
() => new Set(defaultColumns.map((c) => c.key)),
|
||||
);
|
||||
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
|
||||
const [orderedKeys, setOrderedKeys] = useState<string[]>(
|
||||
() => defaultColumns.map((c) => c.key),
|
||||
);
|
||||
// 초기 filterConfig: GRID_COLUMNS에 있는 컬럼만 필터 가능 (전부 비활성)
|
||||
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"]>(
|
||||
() =>
|
||||
defaultColumns.map((c) => ({
|
||||
columnName: c.key,
|
||||
displayName: (c as any).label || c.key,
|
||||
enabled: false,
|
||||
filterType: "text" as const,
|
||||
width: 25,
|
||||
})),
|
||||
);
|
||||
|
||||
/** TableSettingsModal onSave에 전달할 콜백 */
|
||||
const applySettings = useCallback(
|
||||
(settings: TableSettings) => {
|
||||
const visible = new Set<string>();
|
||||
const widths: Record<string, number> = {};
|
||||
const order: string[] = [];
|
||||
|
||||
for (const cs of settings.columns) {
|
||||
if (cs.visible) {
|
||||
visible.add(cs.columnName);
|
||||
widths[cs.columnName] = cs.width;
|
||||
order.push(cs.columnName);
|
||||
}
|
||||
}
|
||||
|
||||
// settings에 없는 새 컬럼은 보이도록 추가
|
||||
for (const col of defaultColumns) {
|
||||
if (!settings.columns.find((c) => c.columnName === col.key)) {
|
||||
visible.add(col.key);
|
||||
order.push(col.key);
|
||||
}
|
||||
}
|
||||
|
||||
setVisibleKeys(visible);
|
||||
setColumnWidths(widths);
|
||||
setOrderedKeys(order);
|
||||
|
||||
// 화면에 표시된 컬럼만 필터 가능하도록 제한
|
||||
setFilterConfig(
|
||||
settings.filters?.filter((f) => visible.has(f.columnName)),
|
||||
);
|
||||
},
|
||||
[defaultColumns],
|
||||
);
|
||||
|
||||
// 마운트 시 저장된 설정 복원
|
||||
useEffect(() => {
|
||||
const saved = loadTableSettings(settingsId);
|
||||
if (saved) applySettings(saved);
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
/** 설정이 적용된 컬럼 목록 (순서 + 표시 필터 적용) */
|
||||
const visibleColumns = useMemo((): T[] => {
|
||||
const colMap = new Map(defaultColumns.map((c) => [c.key, c]));
|
||||
const result: T[] = [];
|
||||
|
||||
// 저장된 순서대로
|
||||
for (const key of orderedKeys) {
|
||||
if (visibleKeys.has(key)) {
|
||||
const col = colMap.get(key);
|
||||
if (col) result.push(col);
|
||||
}
|
||||
}
|
||||
|
||||
// orderedKeys에 없는 컬럼 (새로 추가된 것)
|
||||
for (const col of defaultColumns) {
|
||||
if (!orderedKeys.includes(col.key) && visibleKeys.has(col.key)) {
|
||||
result.push(col);
|
||||
}
|
||||
}
|
||||
|
||||
return result.length > 0 ? result : defaultColumns;
|
||||
}, [defaultColumns, orderedKeys, visibleKeys]);
|
||||
|
||||
/** 컬럼 표시 여부 확인 */
|
||||
const isVisible = useCallback((key: string) => visibleKeys.has(key), [visibleKeys]);
|
||||
|
||||
/** 컬럼 너비 가져오기 (설정값 or undefined) */
|
||||
const getWidth = useCallback(
|
||||
(key: string): number | undefined => columnWidths[key],
|
||||
[columnWidths],
|
||||
);
|
||||
|
||||
return {
|
||||
/** 모달 open 상태 */
|
||||
open,
|
||||
/** 모달 open 상태 setter */
|
||||
setOpen,
|
||||
/** web-types API 호출용 테이블명 */
|
||||
tableName,
|
||||
/** localStorage 키 */
|
||||
settingsId,
|
||||
/** TableSettingsModal onSave 콜백 */
|
||||
applySettings,
|
||||
/** 설정 적용된 컬럼 배열 (순서 + 표시 필터) */
|
||||
visibleColumns,
|
||||
/** 특정 컬럼 표시 여부 */
|
||||
isVisible,
|
||||
/** 특정 컬럼 너비 (px) */
|
||||
getWidth,
|
||||
/** 필터 설정 */
|
||||
filterConfig,
|
||||
/** GRID_COLUMNS 기본 컬럼 키 목록 (TableSettingsModal defaultVisibleKeys용) */
|
||||
defaultVisibleKeys: defaultColumns.map((c) => c.key),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user