This commit is contained in:
SeongHyun Kim
2026-04-05 17:45:55 +09:00
217 changed files with 78646 additions and 1915 deletions
+257
View File
@@ -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"] });
},
});
}
+186
View File
@@ -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 };
}
+75
View File
@@ -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 };
}
+146
View File
@@ -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,
};
}
+153
View File
@@ -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),
};
}