feat: [제품검사 성적서] 8컬럼 동적 렌더링 + FQC 모드 기본값

- FqcDocumentContent: 8컬럼 시각 레이아웃 (No/검사항목/세부항목/검사기준/검사방법/검사주기/측정값/판정)
- rowSpan 병합: category 단독 + method+frequency 복합키 병합
- measurement_type: checkbox→양호/불량, numeric→숫자입력, none→비활성
- InspectionReportModal: FQC 모드 우선 (template 로드 실패 시 legacy fallback)
- Lazy Snapshot 준비 (contentWrapperRef 추가)
This commit is contained in:
2026-03-06 21:47:33 +09:00
parent 295585d8b6
commit 4ea03922a3
2 changed files with 372 additions and 155 deletions

View File

@@ -1,17 +1,20 @@
'use client';
/**
* FQC 제품검사 성적서 - 양식 기반 렌더링
* FQC 제품검사 성적서 - 양식 기반 렌더링 (8컬럼)
*
* documents 시스템의 template 구조를 기반으로 렌더링:
* - 결재라인 (3인: 작성/검토/승인)
* - 기본정보 (7필드: 납품명, 제품명, 발주처, LOT NO, 로트크기, 검사일자, 검사자)
* - 검사항목 테이블 (4컬럼: NO, 검사항목, 검사기준, 판정)
* - 11개 설치 후 최종검사 항목 (모두 visual/checkbox → 적합/부적합)
* - 종합판정 (자동 계산)
* - 검사항목 테이블 (8컬럼 시각 레이아웃)
* - 1~6: section_item 읽기전용 (No, 검사항목, 세부항목, 검사기준, 검사방법, 검사주기)
* - 7~8: template column 편집 (측정값, 판정)
* - rowSpan: category 단독 + method+frequency 복합키 병합
* - measurement_type: checkbox→양호/불량, numeric→숫자입력, none→비활성
* - 종합판정 (자동 계산, measurement_type='none' 제외)
*
* readonly=true → 조회 모드 (InspectionReportModal에서 사용)
* readonly=false → 편집 모드 (ProductInspectionInputModal 대체)
* readonly=true → 조회 모드
* readonly=false → 편집 모드
*/
import { useState, useMemo, useCallback, useImperativeHandle, forwardRef } from 'react';
@@ -55,6 +58,73 @@ interface FqcDocumentContentProps {
type JudgmentValue = '적합' | '부적합' | null;
// ===== rowSpan 병합 유틸 =====
/** 단일 필드 기준 연속 rowSpan 계산 (category용) */
function buildFieldRowSpan(items: FqcTemplateItem[], field: 'category') {
const spans = new Map<number, number>();
const covered = new Set<number>();
let i = 0;
while (i < items.length) {
const value = items[i][field];
if (!value || value === '-') { i++; continue; }
let span = 1;
while (i + span < items.length && items[i + span][field] === value) {
covered.add(i + span);
span++;
}
if (span > 1) spans.set(i, span);
i += span;
}
return { spans, covered };
}
/** 복합 키 기준 연속 rowSpan 계산 (method+frequency용) */
function buildCompositeRowSpan(items: FqcTemplateItem[]) {
const spans = new Map<number, number>();
const covered = new Set<number>();
let i = 0;
while (i < items.length) {
const method = items[i].method || '';
const freq = items[i].frequency || '';
if (!method && !freq) { i++; continue; }
const key = `${method}|${freq}`;
let span = 1;
while (i + span < items.length) {
const nextMethod = items[i + span].method || '';
const nextFreq = items[i + span].frequency || '';
if (!nextMethod && !nextFreq) break;
if (`${nextMethod}|${nextFreq}` !== key) break;
covered.add(i + span);
span++;
}
if (span > 1) spans.set(i, span);
i += span;
}
return { spans, covered };
}
/** category별 그룹 번호 생성 */
function buildCategoryNumbers(items: FqcTemplateItem[]): Map<number, number> {
const numbers = new Map<number, number>();
let num = 0;
let lastCategory = '';
for (let i = 0; i < items.length; i++) {
const cat = items[i].category;
if (cat && cat !== '-' && cat !== lastCategory) {
num++;
lastCategory = cat;
}
numbers.set(i, num);
}
return numbers;
}
// ===== Component =====
export const FqcDocumentContent = forwardRef<FqcDocumentContentRef, FqcDocumentContentProps>(
@@ -75,31 +145,52 @@ export const FqcDocumentContent = forwardRef<FqcDocumentContentRef, FqcDocumentC
[template.sections]
);
// 판정 컬럼 ID 찾기
const sectionItems = useMemo(
() => dataSection?.items.sort((a, b) => a.sortOrder - b.sortOrder) ?? [],
[dataSection]
);
// 컬럼 ID 찾기
const judgmentColumnId = useMemo(
() => template.columns.find(c => c.label === '판정')?.id ?? null,
[template.columns]
);
const measurementColumnId = useMemo(
() => template.columns.find(c => c.label === '측정값')?.id ?? null,
[template.columns]
);
// 기존 문서 데이터에서 판정값 추출
// rowSpan 계산
const categoryCoverage = useMemo(() => buildFieldRowSpan(sectionItems, 'category'), [sectionItems]);
const methodFreqCoverage = useMemo(() => buildCompositeRowSpan(sectionItems), [sectionItems]);
const categoryNumbers = useMemo(() => buildCategoryNumbers(sectionItems), [sectionItems]);
// 기존 문서 데이터에서 판정값 + 측정값 추출
const initialJudgments = useMemo(() => {
const map: Record<number, JudgmentValue> = {};
if (!dataSection || !judgmentColumnId) return map;
for (const d of documentData) {
if (
d.sectionId === dataSection.id &&
d.columnId === judgmentColumnId &&
d.fieldKey === 'result'
) {
if (d.sectionId === dataSection.id && d.columnId === judgmentColumnId && d.fieldKey === 'result') {
map[d.rowIndex] = (d.fieldValue as JudgmentValue) ?? null;
}
}
return map;
}, [documentData, dataSection, judgmentColumnId]);
// 판정 상태 (편집 모드용)
const initialMeasurements = useMemo(() => {
const map: Record<number, string> = {};
if (!dataSection || !measurementColumnId) return map;
for (const d of documentData) {
if (d.sectionId === dataSection.id && d.columnId === measurementColumnId && d.fieldKey === 'measured_value') {
map[d.rowIndex] = d.fieldValue ?? '';
}
}
return map;
}, [documentData, dataSection, measurementColumnId]);
// 상태 (편집 모드용)
const [judgments, setJudgments] = useState<Record<number, JudgmentValue>>(initialJudgments);
const [measurements, setMeasurements] = useState<Record<number, string>>(initialMeasurements);
// 판정 토글
const toggleJudgment = useCallback(
@@ -113,41 +204,32 @@ export const FqcDocumentContent = forwardRef<FqcDocumentContentRef, FqcDocumentC
[readonly]
);
// 종합판정 자동 계산
// 측정값 변경
const updateMeasurement = useCallback(
(rowIndex: number, value: string) => {
if (readonly) return;
setMeasurements(prev => ({ ...prev, [rowIndex]: value }));
},
[readonly]
);
// 종합판정 자동 계산 (measurement_type='none' 제외)
const overallJudgment = useMemo(() => {
if (!dataSection) return null;
const items = dataSection.items;
if (items.length === 0) return null;
const activeItems = sectionItems.filter(item => item.measurementType !== 'none');
if (activeItems.length === 0) return null;
const values = items.map((_, idx) => judgments[idx]);
const activeIndices = sectionItems
.map((item, idx) => item.measurementType !== 'none' ? idx : -1)
.filter(idx => idx >= 0);
const values = activeIndices.map(idx => judgments[idx]);
const hasValue = values.some(v => v !== undefined && v !== null);
if (!hasValue) return null;
if (values.some(v => v === '부적합')) return '불합격' as const;
if (values.every(v => v === '적합')) return '합격' as const;
return null;
}, [dataSection, judgments]);
// 기본필드 값 조회
const getBasicFieldValue = useCallback(
(fieldKey: string): string => {
const field = template.basicFields.find(f => f.fieldKey === fieldKey);
if (!field) return '';
// bf_{id} 형식 (mng show.blade.php 호환)
const bfKey = `bf_${field.id}`;
if (basicFieldValues[bfKey]) return basicFieldValues[bfKey];
const found = documentData.find(d => d.fieldKey === bfKey && !d.sectionId);
if (found?.fieldValue) return found.fieldValue;
// 레거시 호환: bf_{label} 형식
const legacyKey = `bf_${field.label}`;
if (basicFieldValues[legacyKey]) return basicFieldValues[legacyKey];
const legacyFound = documentData.find(d => d.fieldKey === legacyKey && !d.sectionId);
return legacyFound?.fieldValue ?? '';
},
[basicFieldValues, documentData, template.basicFields]
);
}, [dataSection, sectionItems, judgments]);
// ref를 통해 데이터 추출 (편집 모드에서 저장 시 사용)
useImperativeHandle(ref, () => ({
@@ -160,17 +242,33 @@ export const FqcDocumentContent = forwardRef<FqcDocumentContentRef, FqcDocumentC
field_value: string | null;
}> = [];
if (dataSection && judgmentColumnId) {
dataSection.items.forEach((_, idx) => {
const value = judgments[idx];
if (value) {
records.push({
section_id: dataSection.id,
column_id: judgmentColumnId,
row_index: idx,
field_key: 'result',
field_value: value,
});
if (dataSection) {
sectionItems.forEach((item, idx) => {
// 판정
if (judgmentColumnId && item.measurementType !== 'none') {
const value = judgments[idx];
if (value) {
records.push({
section_id: dataSection.id,
column_id: judgmentColumnId,
row_index: idx,
field_key: 'result',
field_value: value,
});
}
}
// 측정값
if (measurementColumnId && item.measurementType !== 'none') {
const value = measurements[idx];
if (value !== undefined && value !== '') {
records.push({
section_id: dataSection.id,
column_id: measurementColumnId,
row_index: idx,
field_key: 'measured_value',
field_value: value,
});
}
}
});
}
@@ -203,7 +301,6 @@ export const FqcDocumentContent = forwardRef<FqcDocumentContentRef, FqcDocumentC
const sorted = [...template.basicFields].sort((a, b) => a.sortOrder - b.sortOrder);
const pairs: Array<{ label: string; value: string }> = [];
for (const field of sorted) {
// bf_{id} 형식 우선, 레거시 bf_{label} fallback
const bfKey = `bf_${field.id}`;
const legacyKey = `bf_${field.label}`;
const value = basicFieldValues[bfKey]
@@ -249,7 +346,6 @@ export const FqcDocumentContent = forwardRef<FqcDocumentContentRef, FqcDocumentC
</div>
<table className="w-full">
<tbody>
{/* 2열씩 표시 */}
{Array.from({ length: Math.ceil(basicFieldPairs.length / 2) }, (_, rowIdx) => {
const left = basicFieldPairs[rowIdx * 2];
const right = basicFieldPairs[rowIdx * 2 + 1];
@@ -307,44 +403,100 @@ export const FqcDocumentContent = forwardRef<FqcDocumentContentRef, FqcDocumentC
</div>
))}
{/* 검사항목 테이블 */}
{/* 검사항목 테이블 (8컬럼 시각 레이아웃) */}
{dataSection && (
<table className="w-full border-collapse border border-gray-400 mb-4">
<thead>
<tr className="bg-gray-100">
{template.columns
.sort((a, b) => a.sortOrder - b.sortOrder)
.map(col => (
<th
key={col.id}
className="border border-gray-400 px-2 py-1 text-center"
style={col.width ? { width: col.width } : undefined}
>
{col.label}
</th>
))}
<th className="border border-gray-400 px-2 py-1 w-10 text-center">No.</th>
<th className="border border-gray-400 px-2 py-1 w-24"></th>
<th className="border border-gray-400 px-2 py-1"></th>
<th className="border border-gray-400 px-2 py-1"></th>
<th className="border border-gray-400 px-2 py-1 w-16 text-center whitespace-pre-line">{'검사\n방법'}</th>
<th className="border border-gray-400 px-2 py-1 w-16 text-center whitespace-pre-line">{'검사\n주기'}</th>
<th className="border border-gray-400 px-2 py-1 w-16 text-center"></th>
<th className="border border-gray-400 px-2 py-1 w-28 text-center"></th>
</tr>
</thead>
<tbody>
{dataSection.items
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((item, idx) => (
<InspectionRow
key={item.id}
item={item}
rowIndex={idx}
judgment={judgments[idx] ?? null}
onToggleJudgment={toggleJudgment}
readonly={readonly}
/>
))}
{sectionItems.map((item, idx) => (
<tr key={item.id} className="border-b border-gray-300">
{/* 1. No. — category 그룹 병합과 동일 */}
{!categoryCoverage.covered.has(idx) && (
<td
className="border border-gray-400 px-2 py-1 text-center align-middle font-medium"
rowSpan={categoryCoverage.spans.get(idx) || 1}
>
{categoryNumbers.get(idx)}
</td>
)}
{/* 2. 검사항목 — category 그룹 병합 */}
{!categoryCoverage.covered.has(idx) && (
<td
className="border border-gray-400 px-2 py-1 align-middle font-medium whitespace-pre-line"
rowSpan={categoryCoverage.spans.get(idx) || 1}
>
{item.category || '-'}
</td>
)}
{/* 3. 세부항목 */}
<td className="border border-gray-400 px-2 py-1 whitespace-pre-line">
{item.itemName === '-' ? '' : item.itemName}
</td>
{/* 4. 검사기준 */}
<td className="border border-gray-400 px-2 py-1 whitespace-pre-line">
{item.standard || '-'}
</td>
{/* 5. 검사방법 — method+frequency 복합키 병합 */}
{!methodFreqCoverage.covered.has(idx) && (
<td
className="border border-gray-400 px-2 py-1 text-center align-middle whitespace-pre-line"
rowSpan={methodFreqCoverage.spans.get(idx) || 1}
>
{item.method || ''}
</td>
)}
{/* 6. 검사주기 — method+frequency 복합키 병합 (동일 span) */}
{!methodFreqCoverage.covered.has(idx) && (
<td
className="border border-gray-400 px-2 py-1 text-center align-middle whitespace-pre-line"
rowSpan={methodFreqCoverage.spans.get(idx) || 1}
>
{item.frequency || ''}
</td>
)}
{/* 7. 측정값 */}
<td className="border border-gray-400 px-1 py-1 text-center align-middle">
<MeasurementCell
item={item}
rowIndex={idx}
value={measurements[idx] ?? ''}
judgment={judgments[idx] ?? null}
onChange={updateMeasurement}
onToggle={toggleJudgment}
readonly={readonly}
type="measurement"
/>
</td>
{/* 8. 판정 */}
<td className="border border-gray-400 px-1 py-1 text-center align-middle">
<MeasurementCell
item={item}
rowIndex={idx}
value={measurements[idx] ?? ''}
judgment={judgments[idx] ?? null}
onChange={updateMeasurement}
onToggle={toggleJudgment}
readonly={readonly}
type="judgment"
/>
</td>
</tr>
))}
{dataSection.items.length === 0 && (
{sectionItems.length === 0 && (
<tr>
<td
colSpan={template.columns.length}
className="border border-gray-400 px-2 py-4 text-center text-gray-400"
>
<td colSpan={8} className="border border-gray-400 px-2 py-4 text-center text-gray-400">
.
</td>
</tr>
@@ -354,7 +506,7 @@ export const FqcDocumentContent = forwardRef<FqcDocumentContentRef, FqcDocumentC
<tr>
<td
className="border border-gray-400 bg-gray-100 px-2 py-2 font-bold text-center"
colSpan={template.columns.length - 1}
colSpan={7}
>
</td>
@@ -386,69 +538,116 @@ export const FqcDocumentContent = forwardRef<FqcDocumentContentRef, FqcDocumentC
}
);
// ===== 검사항목 행 =====
// ===== 측정값/판정 셀 =====
interface InspectionRowProps {
interface MeasurementCellProps {
item: FqcTemplateItem;
rowIndex: number;
value: string;
judgment: JudgmentValue;
onToggleJudgment: (rowIndex: number, value: JudgmentValue) => void;
onChange: (rowIndex: number, value: string) => void;
onToggle: (rowIndex: number, value: JudgmentValue) => void;
readonly: boolean;
type: 'measurement' | 'judgment';
}
function InspectionRow({ item, rowIndex, judgment, onToggleJudgment, readonly }: InspectionRowProps) {
function MeasurementCell({ item, rowIndex, value, judgment, onChange, onToggle, readonly, type }: MeasurementCellProps) {
// none → 비활성
if (item.measurementType === 'none') {
return <span className="text-gray-300">-</span>;
}
if (type === 'measurement') {
// checkbox → 양호/불량 텍스트
if (item.measurementType === 'checkbox') {
if (readonly) {
return <span className="text-[10px]">{value || '-'}</span>;
}
return (
<div className="flex items-center justify-center gap-1">
<button
type="button"
onClick={() => onChange(rowIndex, value === '양호' ? '' : '양호')}
className={`px-1 py-0.5 rounded text-[9px] border transition-colors ${
value === '양호'
? 'bg-blue-600 text-white border-blue-600'
: 'bg-white text-gray-500 border-gray-300 hover:border-blue-400'
}`}
>
</button>
<button
type="button"
onClick={() => onChange(rowIndex, value === '불량' ? '' : '불량')}
className={`px-1 py-0.5 rounded text-[9px] border transition-colors ${
value === '불량'
? 'bg-red-600 text-white border-red-600'
: 'bg-white text-gray-500 border-gray-300 hover:border-red-400'
}`}
>
</button>
</div>
);
}
// numeric → 숫자 입력
if (item.measurementType === 'numeric') {
if (readonly) {
return <span className="text-[10px]">{value || '-'}</span>;
}
return (
<input
type="number"
value={value}
onChange={e => onChange(rowIndex, e.target.value)}
className="w-full text-center text-[10px] border border-gray-300 rounded px-1 py-0.5 focus:outline-none focus:border-blue-400"
placeholder="-"
/>
);
}
return <span className="text-gray-300">-</span>;
}
// type === 'judgment'
if (readonly) {
return (
<div className="flex items-center justify-center gap-1 text-[10px]">
<span className={judgment === '적합' ? 'font-bold text-blue-600' : 'text-gray-400'}>
{judgment === '적합' ? '■' : '□'}
</span>
<span className={judgment === '부적합' ? 'font-bold text-red-600' : 'text-gray-400'}>
{judgment === '부적합' ? '■' : '□'}
</span>
</div>
);
}
return (
<tr className="border-b border-gray-300">
{/* NO */}
<td className="border border-gray-400 px-2 py-1 text-center align-middle font-medium w-10">
{rowIndex + 1}
</td>
{/* 검사항목 */}
<td className="border border-gray-400 px-2 py-1 align-middle">
{item.itemName}
</td>
{/* 검사기준 */}
<td className="border border-gray-400 px-2 py-1 align-middle">
{item.standard || item.frequency || '-'}
</td>
{/* 판정 */}
<td className="border border-gray-400 px-1 py-1 text-center align-middle w-28">
{readonly ? (
<div className="flex items-center justify-center gap-1 text-[10px]">
<span className={judgment === '적합' ? 'font-bold text-blue-600' : 'text-gray-400'}>
{judgment === '적합' ? '■' : '□'}
</span>
<span className={judgment === '부적합' ? 'font-bold text-red-600' : 'text-gray-400'}>
{judgment === '부적합' ? '■' : '□'}
</span>
</div>
) : (
<div className="flex items-center justify-center gap-2">
<button
type="button"
onClick={() => onToggleJudgment(rowIndex, '적합')}
className={`px-2 py-0.5 rounded text-[10px] border transition-colors ${
judgment === '적합'
? 'bg-blue-600 text-white border-blue-600'
: 'bg-white text-gray-500 border-gray-300 hover:border-blue-400'
}`}
>
</button>
<button
type="button"
onClick={() => onToggleJudgment(rowIndex, '부적합')}
className={`px-2 py-0.5 rounded text-[10px] border transition-colors ${
judgment === '부적합'
? 'bg-red-600 text-white border-red-600'
: 'bg-white text-gray-500 border-gray-300 hover:border-red-400'
}`}
>
</button>
</div>
)}
</td>
</tr>
<div className="flex items-center justify-center gap-1">
<button
type="button"
onClick={() => onToggle(rowIndex, '적합')}
className={`px-1.5 py-0.5 rounded text-[9px] border transition-colors ${
judgment === '적합'
? 'bg-blue-600 text-white border-blue-600'
: 'bg-white text-gray-500 border-gray-300 hover:border-blue-400'
}`}
>
</button>
<button
type="button"
onClick={() => onToggle(rowIndex, '부적합')}
className={`px-1.5 py-0.5 rounded text-[9px] border transition-colors ${
judgment === '부적합'
? 'bg-red-600 text-white border-red-600'
: 'bg-white text-gray-500 border-gray-300 hover:border-red-400'
}`}
>
</button>
</div>
);
}

View File

@@ -11,7 +11,7 @@
* Fallback: 문서가 없는 경우 기존 하드코딩 InspectionReportDocument 사용
*/
import { useState, useCallback, useMemo, useEffect } from 'react';
import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ChevronLeft, ChevronRight, Loader2, AlertCircle } from 'lucide-react';
@@ -51,14 +51,19 @@ export function InspectionReportModal({
const [currentPage, setCurrentPage] = useState(1);
const [inputPage, setInputPage] = useState('1');
// rendered_html 캡처용 ref (Phase 1.3 준비)
const contentWrapperRef = useRef<HTMLDivElement>(null);
// FQC 문서/양식 상태
const [fqcDocument, setFqcDocument] = useState<FqcDocument | null>(null);
const [fqcTemplate, setFqcTemplate] = useState<FqcTemplate | null>(null);
const [isLoadingFqc, setIsLoadingFqc] = useState(false);
const [fqcError, setFqcError] = useState<string | null>(null);
const [templateLoadFailed, setTemplateLoadFailed] = useState(false);
// 양식 기반 모드 사용 여부
const useFqcMode = !!fqcDocumentMap && Object.keys(fqcDocumentMap).length > 0;
// FQC 모드 우선 (fqcDocumentMap 없어도 시도, template 로드 실패 시 fallback)
const hasFqcDocuments = !!fqcDocumentMap && Object.keys(fqcDocumentMap).length > 0;
const useFqcMode = !templateLoadFailed && (hasFqcDocuments || !!fqcTemplate);
// 총 페이지 수
const totalPages = useMemo(() => {
@@ -73,22 +78,25 @@ export function InspectionReportModal({
setInputPage('1');
setFqcDocument(null);
setFqcError(null);
setTemplateLoadFailed(false);
}
}, [open]);
// FQC 양식 로드 (한 번만)
// FQC 양식 로드 (항상 시도, template 로드 실패 시 legacy fallback)
useEffect(() => {
if (!open || !useFqcMode || fqcTemplate) return;
if (!open || fqcTemplate || templateLoadFailed) return;
getFqcTemplate().then(result => {
if (result.success && result.data) {
setFqcTemplate(result.data);
} else {
setTemplateLoadFailed(true);
}
});
}, [open, useFqcMode, fqcTemplate]);
}, [open, fqcTemplate, templateLoadFailed]);
// 페이지 변경 시 FQC 문서 로드
useEffect(() => {
if (!open || !useFqcMode || !orderItems || !fqcDocumentMap) return;
if (!open || !hasFqcDocuments || !orderItems || !fqcDocumentMap) return;
const currentItem = orderItems[currentPage - 1];
if (!currentItem) return;
@@ -243,13 +251,23 @@ export function InspectionReportModal({
<p>{fqcError}</p>
</div>
) : fqcDocument && fqcTemplate ? (
<FqcDocumentContent
template={fqcTemplate}
documentData={fqcDocument.data}
documentNo={fqcDocument.documentNo}
createdDate={formatDate(fqcDocument.createdAt)}
readonly={true}
/>
<div ref={contentWrapperRef}>
<FqcDocumentContent
template={fqcTemplate}
documentData={fqcDocument.data}
documentNo={fqcDocument.documentNo}
createdDate={formatDate(fqcDocument.createdAt)}
readonly={true}
/>
</div>
) : fqcTemplate && !hasFqcDocuments ? (
// template은 있지만 문서가 없는 경우 → legacy fallback
legacyCurrentData ? <InspectionReportDocument data={legacyCurrentData} /> : (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
<AlertCircle className="w-8 h-8 mb-2" />
<p> FQC .</p>
</div>
)
) : (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
<AlertCircle className="w-8 h-8 mb-2" />