st: 기본 셋팅
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user