st: 기본 셋팅

This commit is contained in:
2025-12-18 17:55:20 +09:00
parent 85c1a7a5e3
commit c37f4f15f9
240 changed files with 32045 additions and 1 deletions
+245
View File
@@ -0,0 +1,245 @@
import { useRef, useEffect, useState } from "react"
import RealGrid, { GridFitStyle, type DisplayOptions } from "realgrid"
import { RealGridReact, RGDataColumn } from "realgrid-react"
import type { DataGridProps } from "../type/DataGrid.type"
import JSZip from "jszip"
import * as XLSX from "xlsx"
import { useAlertStore } from "@/shared/lib";
import { Button } from "@common/components";
import { useTabStore } from "@/store/tab-store"
// Window 인터페이스 확장
declare global {
interface Window {
JSZip: typeof JSZip;
}
}
// RealGrid가 jszip을 사용할 수 있도록 전역에 등록
if (typeof window !== "undefined") {
window.JSZip = JSZip;
}
RealGrid.setLicenseKey('upVcPE+wPOmtLjqyBIh9RkM/nBOseBrflwxYpzGZyYm9cY8amGDkiPSoO0A3bI0LS1+jeXPBr4vmDWGWN+mYwx6C1EI62/mceibHCCl3OrbDYCIvknJkCQqgn3X6yKqZI9h9wRtIvbtWMBMOPHCENQuxnxtVqI2reHIsW5fXYGA=');
const DataGrid = ({ columnInfo, rows = [], rowStartNum, isExcelImport = true, fixedColumns }: DataGridProps) => {
const { columns, columnLayout } = columnInfo;
const gridRef = useRef<RealGridReact>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const { setMessage: alert } = useAlertStore((state) => state);
const activeTabId = useTabStore((state) => state.activeTabId);
const menuList = useTabStore((state) => state.menuList);
const [rowData, setRowData] = useState<Record<string, string>[]>([]);
const exportExcel = () => {
if (rowData.length === 0) {
alert('데이터가 없습니다.');
return;
}
if (!gridRef.current?.gridView) return;
const gridView = gridRef.current.gridView;
const menuName = menuList
?.flatMap((m) => m.children || [])
.flatMap((subM) => subM || [])
.find((s) => s.menuEngNm === activeTabId)
?.menuKornNm || 'export';
gridView.exportGrid({
type: 'excel',
target: 'local',
fileName: `${menuName}.xlsx`,
footer: 'hidden',
done: () => {
alert('내보내기가 완료되었습니다.');
},
});
};
const importExcel = () => {
fileInputRef.current?.click();
};
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const reader = new FileReader();
reader.onload = (event) => {
try {
const data = event.target?.result;
if (!data) {
alert('파일을 읽을 수 없습니다.');
return;
}
// SheetJS로 엑셀 파일을 JSON으로 변환
const workbook = XLSX.read(data, { type: 'array', cellDates: true });
const firstSheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[firstSheetName || ''];
if (!worksheet || !worksheet['!ref']) {
alert('워크시트를 읽을 수 없습니다.');
return;
}
const range = XLSX.utils.decode_range(worksheet['!ref']);
const headerRowCount = rowStartNum || 1; // 기본값 1
const headerStartRow = Math.max(0, headerRowCount - 1); // 0-based index
// 각 컬럼별로 헤더 찾기 (rowStartNum 행부터 위로 올라가면서)
const headers: string[] = [];
const headerRowIndexes: number[] = [];
for (let col = range.s.c; col <= range.e.c; col++) {
let headerValue = '';
let headerRowIndex = headerStartRow;
// rowStartNum 행부터 시작해서 위로 올라가면서 헤더 찾기
for (let rowIndex = headerStartRow; rowIndex >= 0; rowIndex--) {
const cell = worksheet[XLSX.utils.encode_cell({ r: rowIndex, c: col })];
if (cell && cell.v !== undefined && cell.v !== null && cell.v !== '') {
headerValue = String(cell.v);
headerRowIndex = rowIndex;
break;
}
}
// 헤더를 찾지 못한 경우 기본값 사용
if (!headerValue) {
headerValue = `Column${col + 1}`;
headerRowIndex = headerStartRow;
}
headers.push(headerValue);
headerRowIndexes.push(headerRowIndex);
}
// 실제 데이터가 시작하는 행 찾기 (헤더가 있는 최대 행 번호 + 1)
const maxHeaderRowIndex = headerRowIndexes.length > 0 ? Math.max(...headerRowIndexes) : headerStartRow;
const dataStartRow = maxHeaderRowIndex + 1;
// 데이터 행 읽기
const dataRows: Record<string, string>[] = [];
for (let row = dataStartRow; row <= range.e.r; row++) {
const rowData: Record<string, string> = {};
let hasData = false;
for (let colIndex = 0; colIndex < headers.length; colIndex++) {
const col = range.s.c + colIndex;
const cell = worksheet[XLSX.utils.encode_cell({ r: row, c: col })];
const headerName = headers[colIndex] || `Column${colIndex + 1}`;
const cellValue = cell && cell.v !== undefined && cell.v !== null
? String(cell.v)
: '';
if (cellValue !== '') {
hasData = true;
}
rowData[headerName] = cellValue;
}
// 데이터가 하나라도 있는 행만 추가
if (hasData) {
dataRows.push(rowData);
}
}
setRowData(dataRows);
} catch (error) {
console.error('엑셀 파일 파싱 오류:', error);
alert('엑셀 파일 파싱 중 오류가 발생했습니다.');
} finally {
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
};
reader.onerror = () => {
alert('파일 읽기 중 오류가 발생했습니다.');
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
reader.readAsArrayBuffer(file);
} catch (error) {
console.error('엑셀 파일 가져오기 오류:', error);
alert('엑셀 파일 가져오기 중 오류가 발생했습니다.');
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
};
useEffect(() => {
if (!gridRef.current?.gridView) return;
const gridView = gridRef.current.gridView;
(gridView.displayOptions as DisplayOptions).fitStyle = GridFitStyle.EVEN;
gridView.header.height = 60;
if (rows.length > 0) {
setRowData(rows);
}
// 고정 컬럼 설정
if (fixedColumns !== undefined && fixedColumns > 0) {
gridView.setFixedOptions({
colCount: fixedColumns,
});
}
}, [rows, columns, columnLayout, fixedColumns]);
return (
<div className="flex flex-col gap-[0.6rem] mx-[0.6rem]">
<input
ref={fileInputRef}
type="file"
accept=".xlsx,.xls,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-excel"
style={{ display: 'none' }}
onChange={handleFileChange}
/>
<div className="flex justify-end items-center gap-[0.6rem]">
{isExcelImport &&
<Button className="h-[3.2rem] px-6 bg-gradient-green hover:bg-gradient-green-hover" type="button" onClick={importExcel}>
<i className="diveicon di-file-excel translate-y-[1px]" />
<span className="translate-y-[-1px]">가져오기</span>
</Button>
}
<Button className="h-[3.2rem] px-6 bg-gradient-green hover:bg-gradient-green-hover" type="button" onClick={exportExcel}>
<i className="diveicon di-file-excel translate-y-[1px]" />
<span className="translate-y-[-1px]">내보내기</span>
</Button>
</div>
<div className="h-[65vh]">
<RealGridReact
ref={gridRef}
autoGenerateField={true}
layout={columnLayout}
rows={rowData}
>
{columns.map((column) => (
<RGDataColumn
key={column.name}
name={column.name}
fieldName={column.fieldName}
header={column.header}
// autoFilter={true}
/>
))}
</RealGridReact>
</div>
</div>
);
};
export default DataGrid;
@@ -0,0 +1,218 @@
"use client";
import { useState } from "react";
import useSWR from 'swr';
import {
Button,
Input,
Label,
Select,
SelectTrigger,
SelectContent,
SelectItem,
SelectValue
}
from "@common/components";
import { useAlertStore } from "@/shared/lib";
import { type SearchFormProps, type SearchFormValues, defaultSearchFormProps } from "../type/SearchForm.type";
import { statisticsAPi } from "@/entities/statistics/api/statistics-service";
import type { ApiResponse, Sido, Sgg } from "@/entities/statistics/model/statistics-interface";
const getSidoListFetcher = async (): Promise<ApiResponse<Sido[]>> => {
return statisticsAPi.getSidoList();
};
const getSggListFetcher = async ([_key, sidoName]: [string, string]): Promise<ApiResponse<Sgg[]>> => {
return statisticsAPi.getSggList(sidoName);
};
const SearchForm = (props: SearchFormProps = {}) => {
const { setMessage: alert } = useAlertStore((state) => state);
const mergedProps = { ...defaultSearchFormProps, ...props };
const [values, setValues] = useState<SearchFormValues>({
sido: props.defaultValues?.sido || "",
sgg: props.defaultValues?.sgg || "",
year: props.defaultValues?.year || "",
order: props.defaultValues?.order || "",
fctName: props.defaultValues?.fctName || "",
});
const { data: sidoList } = useSWR<ApiResponse<Sido[]>>(
"sido-list",
getSidoListFetcher,
{
revalidateOnFocus: false,
revalidateOnReconnect: false,
revalidateIfStale: false,
dedupingInterval: 2000,
shouldRetryOnError: false,
onSuccess: (data) => {
if (data.status !== 'success') {
alert(`시도 목록을 불러오는데 실패했습니다.
새로고침 후 다시 시도해주세요.`);
}
},
onError: () => {
alert(`시도 목록을 불러오는데 실패했습니다.
새로고침 후 다시 시도해주세요.`);
},
}
);
const { data: sggList } = useSWR<ApiResponse<Sgg[]>>(
values.sido && values.sido !== "ALL" ? ["sgg-list", values.sido] : null,
getSggListFetcher,
{
revalidateOnFocus: false,
revalidateOnReconnect: false,
revalidateIfStale: false,
dedupingInterval: 2000,
shouldRetryOnError: false,
onSuccess: (data) => {
if (data.status !== 'success') {
alert(`시군구 목록을 불러오는데 실패했습니다.
새로고침 후 다시 시도해주세요.`);
}
},
onError: () => {
alert(`시군구 목록을 불러오는데 실패했습니다.
새로고침 후 다시 시도해주세요.`);
},
}
);
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
props.onSubmit?.(values);
};
const handleReset = () => {
const resetValues: SearchFormValues = {
sido: "",
sgg: "",
year: "",
order: "",
fctName: "",
};
setValues(resetValues);
props.onReset?.();
props.onSubmit?.(resetValues);
};
return (
<div className="card card-border">
<form onSubmit={handleSubmit}>
<div className="flex items-center gap-8 p-2">
{mergedProps.sido &&
<div className="flex items-center gap-2">
<Label className="text-label">
시도
</Label>
<Select value={values.sido} onValueChange={(value) => setValues((prev) => ({ ...prev, sido: value, sgg: 'ALL' }))}>
<SelectTrigger className="w-[12rem]">
<SelectValue placeholder="전체"></SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">전체</SelectItem>
{sidoList?.data?.map((sido) => (
<SelectItem key={sido.adstDstrCd} value={sido.sido}>
{sido.sido}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
}
{mergedProps.sgg &&
<div className="flex items-center gap-2">
<Label className="text-label">
시군구
</Label>
<Select value={values.sgg} onValueChange={(value) => setValues({ ...values, sgg: value })}>
<SelectTrigger className="w-[12rem]">
<SelectValue placeholder="전체"></SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">전체</SelectItem>
{sggList?.data?.map((sgg) => (
<SelectItem key={sgg.adstDstrCd} value={sgg.gugun}>
{sgg.gugun}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
}
{mergedProps.year &&
<div className="flex items-center gap-2">
<Label className="text-label">
년도
</Label>
<Select value={values.year} onValueChange={(value) => setValues({ ...values, year: value })}>
<SelectTrigger className="w-[10rem]">
<SelectValue placeholder="전체"></SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">전체</SelectItem>
</SelectContent>
</Select>
</div>
}
{mergedProps.order &&
<div className="flex items-center gap-2">
<Label className="text-label">
차수
</Label>
<Select value={values.order} onValueChange={(value) => setValues({ ...values, order: value })}>
<SelectTrigger className="w-[10rem]">
<SelectValue placeholder="전체"></SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">전체</SelectItem>
</SelectContent>
</Select>
</div>
}
<div className="flex items-center gap-2 ml-auto">
<Button color="green" className="h-[3.2rem]" type="button" onClick={handleReset}>
초기화
</Button>
<Button className="h-[3.2rem]" type="submit">
조회
</Button>
</div>
</div>
{(mergedProps.fctName || mergedProps.userMenual) &&
<div className="flex items-center p-2">
{mergedProps.fctName &&
<div className="flex items-center gap-2">
<Label htmlFor="liSnSearchInput" className="mr-2 text-label">
시설명
</Label>
<Input
className="h-[3.2rem] w-[14rem] rounded-sm !text-[1.3rem] mr-4"
id="liSnSearchInput"
value={values.fctName}
onChange={(e) => setValues({ ...values, fctName: e.target.value })}
autoComplete="off"
/>
</div>
}
{mergedProps.userMenual &&
<div className="flex items-center gap-2 ml-auto">
<Button className="h-[3.2rem]" type="button">
<i className="diveicon di-person-fill" />
사용자 메뉴얼
</Button>
</div>
}
</div>
}
</form>
</div>
);
};
export default SearchForm;