import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } export function formatDate( date: Date | string | number, locale: string = "en-US", opts: Intl.DateTimeFormatOptions = {}, includeTime: boolean = false ) { const dateObj = new Date(date); // 한국 로케일인 경우 하이픈 포맷 사용 if (locale === "ko-KR" || locale === "KR" || locale === "kr") { const year = dateObj.getFullYear(); const month = String(dateObj.getMonth() + 1).padStart(2, "0"); const day = String(dateObj.getDate()).padStart(2, "0"); let result = `${year}-${month}-${day}`; // 시간 포함 옵션이 활성화된 경우 if (includeTime) { const hour = String(dateObj.getHours()).padStart(2, "0"); const minute = String(dateObj.getMinutes()).padStart(2, "0"); const second = String(dateObj.getSeconds()).padStart(2, "0"); result += ` ${hour}:${minute}:${second}`; } return result; } // 다른 로케일은 기존 방식 유지 return new Intl.DateTimeFormat(locale, { month: opts.month ?? "long", day: opts.day ?? "numeric", year: opts.year ?? "numeric", // Add time options when includeTime is true ...(includeTime && { hour: opts.hour ?? "2-digit", minute: opts.minute ?? "2-digit", second: opts.second ?? "2-digit", hour12: opts.hour12 ?? false, // Use 24-hour format by default }), ...opts, // This allows overriding any of the above defaults }).format(dateObj); } // formatDateTime 함수도 같은 방식으로 수정 export function formatDateTime( date: Date | string | number | null | undefined, locale: string = "en-US", opts: Intl.DateTimeFormatOptions = {} ) { if (date === null || date === undefined || date === '') { return ''; // 또는 '-', 'N/A' 등 원하는 기본값 반환 } const dateObj = new Date(date); // 한국 로케일인 경우 하이픈 포맷 사용 if (locale === "ko-KR" || locale === "KR" || locale === "kr") { const year = dateObj.getFullYear(); const month = String(dateObj.getMonth() + 1).padStart(2, "0"); const day = String(dateObj.getDate()).padStart(2, "0"); const hour = String(dateObj.getHours()).padStart(2, "0"); const minute = String(dateObj.getMinutes()).padStart(2, "0"); const second = String(dateObj.getSeconds()).padStart(2, "0"); return `${year}-${month}-${day} ${hour}:${minute}:${second}`; } // 다른 로케일은 기존 방식 유지 return new Intl.DateTimeFormat(locale, { month: opts.month ?? "long", day: opts.day ?? "numeric", year: opts.year ?? "numeric", hour: opts.hour ?? "2-digit", minute: opts.minute ?? "2-digit", second: opts.second ?? "2-digit", hour12: opts.hour12 ?? false, ...opts, }).format(dateObj); } export function toSentenceCase(str: string) { return str .replace(/_/g, " ") .replace(/([A-Z])/g, " $1") .toLowerCase() .replace(/^\w/, (c) => c.toUpperCase()) .replace(/\s+/g, " ") .trim() } /** * @see https://github.com/radix-ui/primitives/blob/main/packages/core/primitive/src/primitive.tsx */ export function composeEventHandlers( originalEventHandler?: (event: E) => void, ourEventHandler?: (event: E) => void, { checkForDefaultPrevented = true } = {} ) { return function handleEvent(event: E) { originalEventHandler?.(event) if ( checkForDefaultPrevented === false || !(event as unknown as Event).defaultPrevented ) { return ourEventHandler?.(event) } } } /** * 바이트 단위의 파일 크기를 사람이 읽기 쉬운 형식으로 변환합니다. * (예: 1024 -> "1 KB", 1536 -> "1.5 KB") * * @param bytes 변환할 바이트 크기 * @param decimals 소수점 자릿수 (기본값: 1) * @returns 포맷된 파일 크기 문자열 */ export const formatFileSize = (bytes: number, decimals: number = 1): string => { if (bytes === 0) return '0 Bytes'; const k = 1024; const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; // 로그 계산으로 적절한 단위 찾기 const i = Math.floor(Math.log(bytes) / Math.log(k)); // 단위에 맞게 값 계산 (소수점 반올림) const value = parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)); return `${value} ${sizes[i]}`; }; export function formatCurrency( value: number, currency: string | null | undefined = "KRW", locale: string = "ko-KR" ): string { return new Intl.NumberFormat(locale, { style: "currency", currency: currency ?? "KRW", // null이나 undefined면 "KRW" 사용 // minimumFractionDigits: 0, // maximumFractionDigits: 2, }).format(value) } /** * YYYYMMDD 형태의 날짜를 YYYY nQ 형태의 분기로 변환 * @param dateString YYYYMMDD 형태의 문자열 (예: "20240315") * @returns YYYY nQ 형태의 문자열 (예: "2024 1Q") */ export function formatDateToQuarter(dateString: string | null | undefined): string { if (!dateString) return "-" // YYYYMMDD 형태인지 확인 if (typeof dateString !== 'string' || dateString.length !== 8) { return "-" } const year = dateString.substring(0, 4) const month = parseInt(dateString.substring(4, 6), 10) // 월을 분기로 변환 let quarter: number if (month >= 1 && month <= 3) { quarter = 1 } else if (month >= 4 && month <= 6) { quarter = 2 } else if (month >= 7 && month <= 9) { quarter = 3 } else if (month >= 10 && month <= 12) { quarter = 4 } else { return "-" // 잘못된 월 } return `${year} ${quarter}Q` } export function formatBytes(bytes: number, decimals: number = 2): string { if (bytes === 0) return "0 Bytes" const k = 1024 const dm = decimals < 0 ? 0 : decimals const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"] const i = Math.floor(Math.log(bytes) / Math.log(k)) return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i] } export async function copyTextToClipboard(text: string) { // 안전 가드: 브라우저 & API 지원 체크 if (typeof navigator !== "undefined" && navigator.clipboard) { try { await navigator.clipboard.writeText(text) return true } catch { /* fall through to fallback */ } } // --- Fallback (execCommand) --- try { const textarea = document.createElement("textarea") textarea.value = text textarea.style.position = "fixed" // iOS textarea.style.opacity = "0" document.body.appendChild(textarea) textarea.focus() textarea.select() const ok = document.execCommand("copy") document.body.removeChild(textarea) return ok } catch { return false } } export function formatHtml(html: string): string { if (!html.trim()) return html; // 한 줄짜리 HTML인지 확인 const lineCount = html.split('\n').length; const tagCount = (html.match(/<[^>]+>/g) || []).length; // 태그가 많은데 줄이 적으면 포맷팅 필요 if (tagCount <= 3 || lineCount >= tagCount / 2) { return html; // 이미 포맷팅된 것으로 보임 } let formatted = html // 태그 앞뒤 공백 정리 .replace(/>\s*<') // 블록 요소들 앞에 줄바꿈 .replace(/(<\/?(div|p|h[1-6]|table|tr|td|th|ul|ol|li|header|footer|section|article)[^>]*>)/gi, '\n$1') // 자체 닫는 태그 뒤에 줄바꿈 .replace(/(<(br|hr|img|input)[^>]*\/?>)/gi, '$1\n') // 여러 줄바꿈을 하나로 .replace(/\n+/g, '\n') .trim(); // 간단한 들여쓰기 const lines = formatted.split('\n'); let indent = 0; const result: string[] = []; for (let line of lines) { line = line.trim(); if (!line) continue; // 닫는 태그면 들여쓰기 감소 if (line.startsWith('')) { const voidTags = ['br', 'hr', 'img', 'input', 'meta', 'link']; const tagName = line.match(/<(\w+)/)?.[1]?.toLowerCase(); if (tagName && !voidTags.includes(tagName) && !line.includes(``)) { indent += 2; } } } return result.join('\n'); } export function compareItemNumber(a?: string, b?: string) { const as = (a ?? "").split(".").map((v) => Number(v)); const bs = (b ?? "").split(".").map((v) => Number(v)); const len = Math.max(as.length, bs.length); for (let i = 0; i < len; i++) { const av = Number.isFinite(as[i]) ? as[i] : Number.NEGATIVE_INFINITY; // 부모가 먼저 const bv = Number.isFinite(bs[i]) ? bs[i] : Number.NEGATIVE_INFINITY; if (av !== bv) return av - bv; } return as.length - bs.length; }