feat: 급여관리 개선 + 설비관리 신규 + 팝업관리/카드관리/가격표 개선

- 급여관리: 상세/등록 다이얼로그 리팩토링, actions/types 확장
- 설비관리: 설비현황/점검/수리 4개 페이지 신규 추가
- 팝업관리: PopupDetail/PopupForm 개선
- 카드관리: CardForm 개선
- IntegratedListTemplateV2, SearchFilter, useColumnSettings 개선
- CLAUDE.md: 페이지 모드 라우팅 패턴 규칙 추가
- 공통 페이지 패턴 가이드 확장
This commit is contained in:
유병철
2026-03-12 21:48:37 +09:00
parent 945a371cdf
commit ca5a9325c6
40 changed files with 10284 additions and 1867 deletions

View File

@@ -0,0 +1,481 @@
'use client';
/**
* 일상점검표 - 그리드 매트릭스 뷰
*
* 설비별 점검항목 × 날짜 매트릭스
* 셀 클릭으로 ○/X/△ 토글
*/
import { useState, useEffect, useCallback } from 'react';
import { useSearchParams } from 'next/navigation';
import { Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { DatePicker } from '@/components/ui/date-picker';
import { Label } from '@/components/ui/label';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import {
getInspectionGrid,
toggleInspectionResult,
resetInspections,
getEquipmentOptions,
} from '@/components/quality/EquipmentManagement/actions';
import type {
EquipmentOptions,
InspectionCycle,
InspectionResult,
} from '@/components/quality/EquipmentManagement/types';
import {
INSPECTION_CYCLE_LABEL,
INSPECTION_CYCLES,
INSPECTION_RESULT_SYMBOL,
} from '@/components/quality/EquipmentManagement/types';
interface GridEquipment {
id: number;
equipmentCode: string;
name: string;
}
interface GridTemplate {
id: number;
itemNo: string;
checkPoint: string;
checkItem: string;
}
interface GridRow {
equipment: GridEquipment;
templates: GridTemplate[];
details: Record<string, InspectionResult>;
canInspect: boolean;
overallJudgment: string | null;
}
interface GridData {
rows: GridRow[];
labels: string[];
nonWorkingDays: string[];
}
export function EquipmentInspectionGrid() {
const searchParams = useSearchParams();
const [cycle, setCycle] = useState<InspectionCycle>('daily');
const [period, setPeriod] = useState<string>(() => {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
});
const [lineFilter, setLineFilter] = useState<string>('all');
const [equipmentFilter, setEquipmentFilter] = useState<string>(() => {
if (typeof window === 'undefined') return 'all';
const eqId = new URLSearchParams(window.location.search).get('equipment_id');
return eqId || 'all';
});
const [options, setOptions] = useState<EquipmentOptions | null>(null);
const [gridData, setGridData] = useState<GridData>({ rows: [], labels: [], nonWorkingDays: [] });
const [isLoading, setIsLoading] = useState(true);
const [showReset, setShowReset] = useState(false);
// 옵션 로드
useEffect(() => {
getEquipmentOptions().then((r) => {
if (r.success && r.data) setOptions(r.data);
});
}, []);
// 그리드 데이터 로드
const loadGrid = useCallback(async () => {
setIsLoading(true);
const result = await getInspectionGrid({
cycle,
period,
productionLine: lineFilter,
equipmentId: equipmentFilter !== 'all' ? Number(equipmentFilter) : undefined,
});
if (result.success && result.data) {
// API는 배열을 반환: [{ equipment, templates, inspection, details, labels, can_inspect }, ...]
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const apiItems = Array.isArray(result.data) ? result.data as any[] : [];
const rows: GridRow[] = apiItems.map((item) => {
// details: API는 "templateItemId_date" 키의 배열 or 빈 배열 → Record<string, InspectionResult> 변환
const detailMap: Record<string, InspectionResult> = {};
if (item.details && typeof item.details === 'object' && !Array.isArray(item.details)) {
for (const [key, arr] of Object.entries(item.details)) {
if (Array.isArray(arr) && arr.length > 0) {
detailMap[key] = (arr[0] as { result: InspectionResult }).result;
}
}
}
return {
equipment: {
id: item.equipment?.id,
equipmentCode: item.equipment?.equipment_code || '',
name: item.equipment?.name || '',
},
templates: (item.templates || []).map((t: Record<string, unknown>) => ({
id: t.id,
itemNo: String(t.item_no ?? ''),
checkPoint: t.check_point || '',
checkItem: t.check_item || '',
})),
details: detailMap,
canInspect: item.can_inspect ?? false,
overallJudgment: item.inspection?.overall_judgment ?? null,
};
});
// labels: 첫 번째 아이템의 labels (object {"1":"1",...} → string[] 변환)
const rawLabels = apiItems.length > 0 ? apiItems[0].labels : {};
const labels: string[] = typeof rawLabels === 'object' && !Array.isArray(rawLabels)
? Object.keys(rawLabels).sort((a, b) => Number(a) - Number(b))
: Array.isArray(rawLabels) ? rawLabels : [];
// 주말(토/일) 계산
const [y, m] = period.split('-').map(Number);
const weekends: string[] = [];
const daysInMonth = new Date(y, m, 0).getDate();
for (let d = 1; d <= daysInMonth; d++) {
const dow = new Date(y, m - 1, d).getDay();
if (dow === 0 || dow === 6) weekends.push(String(d));
}
setGridData({ rows, labels, nonWorkingDays: weekends });
} else {
setGridData({ rows: [], labels: [], nonWorkingDays: [] });
}
setIsLoading(false);
}, [cycle, period, lineFilter, equipmentFilter]);
useEffect(() => {
loadGrid();
}, [loadGrid]);
// 셀 클릭 토글
const handleCellClick = useCallback(async (
equipmentId: number,
templateItemId: number,
dayLabel: string
) => {
// label("1","2"...) → full date("2026-03-01")
const fullDate = `${period}-${dayLabel.padStart(2, '0')}`;
const result = await toggleInspectionResult({
equipmentId,
templateItemId,
checkDate: fullDate,
cycle,
});
if (result.success && result.data) {
setGridData((prev) => {
if (!prev) return prev;
const newRows = prev.rows.map((row) => {
if (row.equipment.id !== equipmentId) return row;
const key = `${templateItemId}_${dayLabel}`;
return {
...row,
details: {
...row.details,
[key]: result.data!.result,
},
};
});
return { ...prev, rows: newRows };
});
} else {
toast.error(result.error || '점검 결과 변경에 실패했습니다.');
}
}, [cycle, period]);
// 전체 초기화
const handleReset = useCallback(async () => {
const result = await resetInspections({ cycle, period });
if (result.success) {
toast.success(`${result.data?.deleted_count || 0}건의 점검 데이터가 초기화되었습니다.`);
setShowReset(false);
await loadGrid();
} else {
toast.error(result.error || '초기화에 실패했습니다.');
}
}, [cycle, period, loadGrid]);
const getResultSymbol = (result: InspectionResult): string => {
if (!result) return '';
return INSPECTION_RESULT_SYMBOL[result] || '';
};
const getResultColor = (result: InspectionResult): string => {
if (!result) return '';
if (result === 'good') return 'text-green-600';
if (result === 'bad') return 'text-red-600';
if (result === 'repaired') return 'text-yellow-600';
return '';
};
return (
<div className="space-y-6 p-3 md:p-6">
<h1 className="text-xl font-bold"></h1>
{/* 주기 탭 버튼 */}
<div className="grid grid-cols-3 gap-1 border-b pb-1 md:flex md:items-center md:gap-1 md:pb-0">
{INSPECTION_CYCLES.map((c) => (
<button
key={c}
className={`px-3 py-2 text-sm font-medium border-b-2 transition-colors text-center ${
cycle === c
? 'border-blue-600 text-blue-600'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
onClick={() => setCycle(c)}
>
{INSPECTION_CYCLE_LABEL[c]}
</button>
))}
</div>
{/* 필터: 점검년월 / 생산라인 / 설비 / 조회 / 전체 초기화 */}
<Card>
<CardContent className="py-5 px-6">
<div className="flex flex-wrap items-end gap-6">
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<DatePicker
value={period ? `${period}-01` : ''}
onChange={(v) => {
if (v) setPeriod(v.substring(0, 7));
}}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Select value={lineFilter} onValueChange={setLineFilter}>
<SelectTrigger className="w-[180px] h-10">
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{(options?.productionLines || []).map((line) => (
<SelectItem key={line} value={line}>
{line}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Select value={equipmentFilter} onValueChange={setEquipmentFilter}>
<SelectTrigger className="w-[180px] h-10">
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{(options?.equipmentList || []).map((eq) => (
<SelectItem key={eq.id} value={String(eq.id)}>
{eq.equipmentCode} {eq.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
variant="outline"
className="h-10 text-red-600 border-red-300 hover:bg-red-50"
onClick={() => setShowReset(true)}
>
</Button>
</div>
</CardContent>
</Card>
{/* 그리드 */}
{isLoading ? (
<div className="flex justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : gridData.rows.length === 0 ? (
<Card>
<CardContent className="p-0">
<div className="flex items-center justify-center" style={{ minHeight: 240 }}>
<div className="text-center space-y-3">
<p className="text-red-500 font-bold text-base"> .</p>
<p className="text-sm text-muted-foreground">
.
</p>
</div>
</div>
</CardContent>
</Card>
) : (
<Card>
<CardHeader>
<CardTitle className="text-base">
{INSPECTION_CYCLE_LABEL[cycle]} - {period}
</CardTitle>
</CardHeader>
<CardContent className="px-2 md:px-6">
{(() => {
const isMobile = typeof window !== 'undefined' && window.innerWidth < 640;
const COL1 = isMobile ? 70 : 120; // 설비
const COL2 = isMobile ? 56 : 100; // 점검부위
const COL3 = isMobile ? 56 : 100; // 점검항목
const bc = '#e5e7eb'; // border color
const stickyHead = (left: number, w: number) => ({
position: 'sticky' as const, left, width: w, minWidth: w, zIndex: 20,
background: '#f3f4f6',
borderRight: `1px solid ${bc}`, borderBottom: `1px solid ${bc}`,
});
const stickyBody = (left: number, w: number) => ({
position: 'sticky' as const, left, width: w, minWidth: w, zIndex: 10,
background: '#fff',
borderRight: `1px solid ${bc}`, borderBottom: `1px solid ${bc}`,
});
const normalHead = (w: number) => ({
width: w, minWidth: w,
background: '#f3f4f6',
borderRight: `1px solid ${bc}`, borderBottom: `1px solid ${bc}`,
});
const normalBody = (w: number) => ({
width: w, minWidth: w,
borderRight: `1px solid ${bc}`, borderBottom: `1px solid ${bc}`,
});
const lastStickyHead = (left: number, w: number) => ({
...stickyHead(left, w),
boxShadow: '4px 0 6px -2px rgba(0,0,0,0.08)',
});
const lastStickyBody = (left: number, w: number) => ({
...stickyBody(left, w),
boxShadow: '4px 0 6px -2px rgba(0,0,0,0.08)',
});
return (
<div className="overflow-x-auto">
<table className="w-full text-xs" style={{ borderCollapse: 'separate', borderSpacing: 0 }}>
<thead>
<tr>
<th className="p-1.5 text-left" style={{ ...(isMobile ? lastStickyHead(0, COL1) : stickyHead(0, COL1)), borderLeft: `1px solid ${bc}`, borderTop: `1px solid ${bc}` }}></th>
<th className="p-1.5 text-left" style={{ ...(isMobile ? { ...normalHead(COL2), borderTop: `1px solid ${bc}` } : { ...stickyHead(COL1, COL2), borderTop: `1px solid ${bc}` }) }}></th>
<th className="p-1.5 text-left" style={{ ...(isMobile ? { ...normalHead(COL3), borderTop: `1px solid ${bc}` } : { ...lastStickyHead(COL1 + COL2, COL3), borderTop: `1px solid ${bc}` }) }}></th>
{gridData.labels.map((label) => {
const isHoliday = gridData.nonWorkingDays.includes(label);
return (
<th
key={label}
className="p-1.5 text-center min-w-[36px]"
style={{
background: isHoliday ? '#fef2f2' : '#f3f4f6',
color: isHoliday ? '#dc2626' : undefined,
fontWeight: isHoliday ? 700 : undefined,
borderRight: `1px solid ${bc}`, borderBottom: `1px solid ${bc}`, borderTop: `1px solid ${bc}`,
}}
>
{label.split('-').pop()}
</th>
);
})}
<th className="p-1.5 text-center min-w-[40px]" style={{ background: '#f3f4f6', borderRight: `1px solid ${bc}`, borderBottom: `1px solid ${bc}`, borderTop: `1px solid ${bc}` }}></th>
</tr>
</thead>
<tbody>
{gridData.rows.map((row) =>
row.templates.length === 0 ? (
<tr key={row.equipment.id}>
<td className="p-1.5" style={{ ...(isMobile ? lastStickyBody(0, COL1) : stickyBody(0, COL1)), borderLeft: `1px solid ${bc}` }}>
<div className="font-medium">{row.equipment.equipmentCode}</div>
<div className="text-muted-foreground">{row.equipment.name}</div>
</td>
<td className="p-1.5 text-center text-muted-foreground" colSpan={2 + gridData.labels.length + 1} style={{ borderRight: `1px solid ${bc}`, borderBottom: `1px solid ${bc}` }}>
</td>
</tr>
) : (
row.templates.map((template, tIdx) => (
<tr key={`${row.equipment.id}-${template.id}`}>
{tIdx === 0 && (
<td
className="p-1.5"
rowSpan={row.templates.length}
style={{ ...(isMobile ? lastStickyBody(0, COL1) : stickyBody(0, COL1)), borderLeft: `1px solid ${bc}` }}
>
<div className="font-medium">{row.equipment.equipmentCode}</div>
<div className="text-muted-foreground">{row.equipment.name}</div>
</td>
)}
<td className="p-1.5" style={isMobile ? normalBody(COL2) : stickyBody(COL1, COL2)}>{template.checkPoint}</td>
<td className="p-1.5" style={isMobile ? normalBody(COL3) : lastStickyBody(COL1 + COL2, COL3)}>{template.checkItem}</td>
{gridData.labels.map((label) => {
const key = `${template.id}_${label}`;
const result = row.details[key];
const isNonWorking = gridData.nonWorkingDays.includes(label);
return (
<td
key={label}
className={`p-0 text-center cursor-pointer hover:bg-blue-50 transition-colors ${getResultColor(result)}`}
style={{
borderRight: `1px solid ${bc}`, borderBottom: `1px solid ${bc}`,
...(isNonWorking ? { background: '#fef2f2' } : {}),
}}
onClick={() =>
row.canInspect &&
handleCellClick(row.equipment.id, template.id, label)
}
>
<span className="text-sm font-bold">{getResultSymbol(result)}</span>
</td>
);
})}
{tIdx === 0 && (
<td
className="p-1.5 text-center font-bold"
rowSpan={row.templates.length}
style={{ borderRight: `1px solid ${bc}`, borderBottom: `1px solid ${bc}` }}
>
{row.overallJudgment || '-'}
</td>
)}
</tr>
))
)
)}
</tbody>
</table>
</div>
);
})()}
{/* 범례 */}
<div className="flex items-center gap-4 mt-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<span className="text-green-600 font-bold"></span>
</span>
<span className="flex items-center gap-1">
<span className="text-red-600 font-bold">X</span>
</span>
<span className="flex items-center gap-1">
<span className="text-yellow-600 font-bold"></span>
</span>
<span className="flex items-center gap-1">
<span className="inline-block w-3 h-3 bg-red-50 border"></span>
</span>
</div>
</CardContent>
</Card>
)}
{/* 전체 초기화 확인 */}
<ConfirmDialog
open={showReset}
onOpenChange={setShowReset}
title="점검 데이터 전체 초기화"
description={`${period} ${INSPECTION_CYCLE_LABEL[cycle]} 점검 데이터를 모두 초기화하시겠습니까? 이 작업은 되돌릴 수 없습니다.`}
confirmText="전체 초기화"
variant="destructive"
onConfirm={handleReset}
/>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,561 @@
'use client';
/**
* 설비 등록/수정 폼
*
* 섹션: 기본정보 / 제조사 정보 / 설치 정보 / 관리자·비고
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { Loader2, Save, X, ImagePlus, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { useMenuStore } from '@/stores/menuStore';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { FormField } from '@/components/molecules/FormField';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
createEquipment,
updateEquipment,
getEquipmentOptions,
getEquipmentDetail,
getManagerOptions,
uploadEquipmentPhoto,
} from './actions';
import type {
EquipmentFormData,
EquipmentOptions,
Equipment,
ManagerOption,
} from './types';
import { EQUIPMENT_STATUS_LABEL } from './types';
const initialFormData: EquipmentFormData = {
equipmentCode: '',
name: '',
equipmentType: '',
specification: '',
manufacturer: '',
modelName: '',
serialNo: '',
location: '',
productionLine: '',
purchaseDate: '',
installDate: '',
purchasePrice: '',
usefulLife: '',
status: 'active',
managerId: '',
subManagerId: '',
memo: '',
};
interface EquipmentFormProps {
/** 수정 모드일 때 설비 ID */
equipmentId?: string;
/** 수정 모드일 때 기존 데이터 */
initialData?: Equipment;
/** 저장 후 콜백 */
onSaveSuccess?: () => void;
}
export function EquipmentForm({ equipmentId, initialData, onSaveSuccess }: EquipmentFormProps) {
const router = useRouter();
const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed);
const isEditMode = !!equipmentId;
const [formData, setFormData] = useState<EquipmentFormData>(initialFormData);
const [options, setOptions] = useState<EquipmentOptions | null>(null);
const [managers, setManagers] = useState<ManagerOption[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isLoading, setIsLoading] = useState(!!equipmentId && !initialData);
const [pendingPhotos, setPendingPhotos] = useState<File[]>([]);
const [photoPreviews, setPhotoPreviews] = useState<string[]>([]);
const photoInputRef = useRef<HTMLInputElement>(null);
// 옵션 + 직원 목록 로드
useEffect(() => {
Promise.all([
getEquipmentOptions(),
getManagerOptions(),
]).then(([optResult, mgrResult]) => {
if (optResult.success && optResult.data) {
setOptions(optResult.data);
}
setManagers(mgrResult);
});
}, []);
// 수정 모드: 기존 데이터 세팅
useEffect(() => {
if (initialData) {
setFormData({
equipmentCode: initialData.equipmentCode,
name: initialData.name,
equipmentType: initialData.equipmentType,
specification: initialData.specification,
manufacturer: initialData.manufacturer,
modelName: initialData.modelName,
serialNo: initialData.serialNo,
location: initialData.location,
productionLine: initialData.productionLine,
purchaseDate: initialData.purchaseDate,
installDate: initialData.installDate,
purchasePrice: initialData.purchasePrice,
usefulLife: initialData.usefulLife ? String(initialData.usefulLife) : '',
status: initialData.status,
managerId: initialData.managerId ? String(initialData.managerId) : '',
subManagerId: initialData.subManagerId ? String(initialData.subManagerId) : '',
memo: initialData.memo,
});
} else if (equipmentId) {
setIsLoading(true);
getEquipmentDetail(equipmentId).then((result) => {
if (result.success && result.data) {
const d = result.data;
setFormData({
equipmentCode: d.equipmentCode,
name: d.name,
equipmentType: d.equipmentType,
specification: d.specification,
manufacturer: d.manufacturer,
modelName: d.modelName,
serialNo: d.serialNo,
location: d.location,
productionLine: d.productionLine,
purchaseDate: d.purchaseDate,
installDate: d.installDate,
purchasePrice: d.purchasePrice,
usefulLife: d.usefulLife ? String(d.usefulLife) : '',
status: d.status,
managerId: d.managerId ? String(d.managerId) : '',
subManagerId: d.subManagerId ? String(d.subManagerId) : '',
memo: d.memo,
});
} else {
toast.error(result.error || '설비 데이터를 불러올 수 없습니다.');
router.push('/quality/equipment');
}
setIsLoading(false);
});
}
}, [equipmentId, initialData, router]);
const handleChange = useCallback((field: keyof EquipmentFormData, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
}, []);
const handlePhotoSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (pendingPhotos.length >= 10) {
toast.error('최대 10장까지 등록 가능합니다.');
return;
}
setPendingPhotos((prev) => [...prev, file]);
setPhotoPreviews((prev) => [...prev, URL.createObjectURL(file)]);
if (photoInputRef.current) photoInputRef.current.value = '';
}, [pendingPhotos.length]);
const handlePhotoRemove = useCallback((index: number) => {
URL.revokeObjectURL(photoPreviews[index]);
setPendingPhotos((prev) => prev.filter((_, i) => i !== index));
setPhotoPreviews((prev) => prev.filter((_, i) => i !== index));
}, [photoPreviews]);
const handleSubmit = useCallback(async () => {
if (!formData.equipmentCode.trim()) {
toast.error('설비코드를 입력하세요.');
return;
}
if (!formData.name.trim()) {
toast.error('설비명을 입력하세요.');
return;
}
setIsSubmitting(true);
try {
const result = isEditMode
? await updateEquipment(equipmentId!, formData)
: await createEquipment(formData);
if (result.success) {
// 신규 등록 시 대기 중인 사진 업로드
if (!isEditMode && pendingPhotos.length > 0 && result.data?.id) {
const newId = String(result.data.id);
let uploadedCount = 0;
for (const file of pendingPhotos) {
const photoResult = await uploadEquipmentPhoto(newId, file);
if (photoResult.success) {
uploadedCount++;
} else {
toast.error(photoResult.error || '사진 업로드에 실패했습니다.');
}
}
if (uploadedCount > 0) {
toast.success(`사진 ${uploadedCount}장이 업로드되었습니다.`);
}
// preview URL 정리
photoPreviews.forEach((url) => URL.revokeObjectURL(url));
}
toast.success(isEditMode ? '설비가 수정되었습니다.' : '설비가 등록되었습니다.');
if (onSaveSuccess) {
onSaveSuccess();
} else {
router.push('/quality/equipment');
}
} else {
toast.error(result.error || '저장에 실패했습니다.');
}
} finally {
setIsSubmitting(false);
}
}, [formData, isEditMode, equipmentId, onSaveSuccess, router, pendingPhotos, photoPreviews]);
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="space-y-6 p-3 md:p-6">
{/* 헤더 */}
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold">{isEditMode ? '설비 수정' : '설비 등록'}</h1>
<Button
variant="link"
className="text-muted-foreground"
onClick={() => router.push('/quality/equipment')}
>
</Button>
</div>
{/* 기본정보 */}
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<FormField
label="설비코드"
required
value={formData.equipmentCode}
onChange={(v) => handleChange('equipmentCode', v)}
placeholder="KD-M-001"
/>
<p className="text-xs text-muted-foreground mt-1">: KD-M-001, KD-S-002</p>
</div>
<FormField
label="설비명"
required
value={formData.name}
onChange={(v) => handleChange('name', v)}
placeholder="포밍기#1"
/>
<div>
<Label></Label>
<div className="mt-1">
<Select
value={formData.equipmentType || '_none'}
onValueChange={(v) => handleChange('equipmentType', v === '_none' ? '' : v)}
>
<SelectTrigger>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_none"></SelectItem>
{(options?.equipmentTypes || []).map((type) => (
<SelectItem key={type} value={type}>
{type}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<FormField
label="규격"
value={formData.specification}
onChange={(v) => handleChange('specification', v)}
placeholder="규격"
/>
</div>
</CardContent>
</Card>
{/* 제조사 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<FormField
label="제조사"
value={formData.manufacturer}
onChange={(v) => handleChange('manufacturer', v)}
placeholder="제조사"
/>
<FormField
label="모델명"
value={formData.modelName}
onChange={(v) => handleChange('modelName', v)}
placeholder="모델명"
/>
<FormField
label="제조번호"
value={formData.serialNo}
onChange={(v) => handleChange('serialNo', v)}
placeholder="제조번호"
/>
</div>
</CardContent>
</Card>
{/* 설치 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
label="위치"
value={formData.location}
onChange={(v) => handleChange('location', v)}
placeholder="1공장-1F"
/>
<div>
<Label></Label>
<div className="mt-1">
<Select
value={formData.productionLine || '_none'}
onValueChange={(v) => handleChange('productionLine', v === '_none' ? '' : v)}
>
<SelectTrigger>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_none"></SelectItem>
{(options?.productionLines || []).map((line) => (
<SelectItem key={line} value={line}>
{line}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<FormField
label="구입일"
type="date"
value={formData.purchaseDate}
onChange={(v) => handleChange('purchaseDate', v)}
/>
<FormField
label="설치일"
type="date"
value={formData.installDate}
onChange={(v) => handleChange('installDate', v)}
/>
<FormField
label="구입가격 (원)"
type="number"
value={formData.purchasePrice}
onChange={(v) => handleChange('purchasePrice', v)}
placeholder="구입가격"
/>
<FormField
label="내용연수 (년)"
type="number"
value={formData.usefulLife}
onChange={(v) => handleChange('usefulLife', v)}
placeholder="내용연수"
/>
</div>
</CardContent>
</Card>
{/* 관리자 / 비고 */}
<Card>
<CardHeader>
<CardTitle className="text-base"> / </CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label> </Label>
<div className="mt-1">
<Select
value={formData.managerId || '_none'}
onValueChange={(v) => handleChange('managerId', v === '_none' ? '' : v)}
>
<SelectTrigger>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_none"></SelectItem>
{managers.map((mgr) => (
<SelectItem key={mgr.id} value={mgr.id}>
{mgr.department ? `${mgr.department} / ` : ''}{mgr.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div>
<Label> </Label>
<div className="mt-1">
<Select
value={formData.subManagerId || '_none'}
onValueChange={(v) => handleChange('subManagerId', v === '_none' ? '' : v)}
>
<SelectTrigger>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_none"></SelectItem>
{managers.map((mgr) => (
<SelectItem key={mgr.id} value={mgr.id}>
{mgr.department ? `${mgr.department} / ` : ''}{mgr.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label></Label>
<div className="mt-1">
<Select
value={formData.status}
onValueChange={(v) => handleChange('status', v)}
>
<SelectTrigger>
<SelectValue placeholder="상태 선택" />
</SelectTrigger>
<SelectContent>
{Object.entries(EQUIPMENT_STATUS_LABEL).map(([val, label]) => (
<SelectItem key={val} value={val}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
<div>
<Label></Label>
<div className="mt-1">
<Textarea
value={formData.memo}
onChange={(e) => handleChange('memo', e.target.value)}
placeholder="비고를 입력하세요"
rows={4}
/>
</div>
</div>
</div>
</CardContent>
</Card>
{/* 설비 사진 */}
{!isEditMode && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-base"> </CardTitle>
<div>
<input
ref={photoInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handlePhotoSelect}
/>
<Button
variant="outline"
size="sm"
type="button"
onClick={() => photoInputRef.current?.click()}
disabled={pendingPhotos.length >= 10}
>
<ImagePlus className="h-4 w-4 mr-1" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{pendingPhotos.length === 0 ? (
<div className="text-center py-8 text-muted-foreground text-sm">
. ( )
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{pendingPhotos.map((file, index) => (
<div key={`photo-${index}`} className="space-y-2">
<div className="relative border rounded-lg overflow-hidden bg-gray-50 group">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={photoPreviews[index]}
alt={file.name}
className="w-full h-auto max-h-[400px] object-contain"
/>
<Button
variant="destructive"
size="sm"
type="button"
className="absolute top-2 right-2 h-7 px-2 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => handlePhotoRemove(index)}
>
<Trash2 className="h-3.5 w-3.5 mr-1" />
</Button>
</div>
<p className="text-xs text-muted-foreground">{file.name}</p>
</div>
))}
</div>
)}
{pendingPhotos.length >= 10 && (
<p className="text-xs text-muted-foreground mt-2"> 10 .</p>
)}
</CardContent>
</Card>
)}
{/* 하단 버튼 (sticky 하단 바) */}
<div className={`fixed bottom-4 left-4 right-4 px-4 py-3 bg-background/95 backdrop-blur rounded-xl border shadow-lg z-50 transition-all duration-300 md:bottom-6 md:px-6 md:right-[48px] ${sidebarCollapsed ? 'md:left-[156px]' : 'md:left-[316px]'} flex items-center justify-between`}>
<Button variant="outline" onClick={() => router.push('/quality/equipment')}>
<X className="h-4 w-4 mr-1" />
</Button>
<Button onClick={handleSubmit} disabled={isSubmitting}>
{isSubmitting ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <Save className="h-4 w-4 mr-1" />}
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,211 @@
'use client';
/**
* 설비 엑셀 Import 페이지
*
* 엑셀 파일 업로드 → 미리보기 → 일괄 등록
*/
import { useState, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { Upload, FileSpreadsheet, Loader2, Download } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
interface ImportRow {
equipmentCode: string;
name: string;
equipmentType: string;
specification: string;
manufacturer: string;
location: string;
productionLine: string;
status: string;
[key: string]: string;
}
export function EquipmentImport() {
const router = useRouter();
const [file, setFile] = useState<File | null>(null);
const [previewData, setPreviewData] = useState<ImportRow[]>([]);
const [isUploading, setIsUploading] = useState(false);
const [isImporting, setIsImporting] = useState(false);
const handleFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const selected = e.target.files?.[0];
if (!selected) return;
if (!selected.name.match(/\.(xlsx|xls|csv)$/i)) {
toast.error('엑셀 파일(.xlsx, .xls) 또는 CSV 파일만 업로드 가능합니다.');
return;
}
setFile(selected);
setPreviewData([]);
}, []);
const handleUploadPreview = useCallback(async () => {
if (!file) {
toast.error('파일을 선택하세요.');
return;
}
setIsUploading(true);
try {
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/api/proxy/equipment/import/preview', {
method: 'POST',
body: formData,
});
const result = await response.json();
if (result.success && result.data) {
setPreviewData(result.data);
toast.success(`${result.data.length}건의 데이터가 확인되었습니다.`);
} else {
toast.error(result.message || '파일 읽기에 실패했습니다.');
}
} catch {
toast.error('파일 업로드 중 오류가 발생했습니다.');
} finally {
setIsUploading(false);
}
}, [file]);
const handleImport = useCallback(async () => {
if (previewData.length === 0) return;
setIsImporting(true);
try {
const formData = new FormData();
formData.append('file', file!);
const response = await fetch('/api/proxy/equipment/import', {
method: 'POST',
body: formData,
});
const result = await response.json();
if (result.success) {
toast.success(`${result.data?.imported || previewData.length}건이 등록되었습니다.`);
router.push('/quality/equipment');
} else {
toast.error(result.message || 'Import에 실패했습니다.');
}
} catch {
toast.error('Import 중 오류가 발생했습니다.');
} finally {
setIsImporting(false);
}
}, [file, previewData, router]);
return (
<div className="space-y-6 p-3 md:p-6">
{/* 헤더 */}
<div>
<h1 className="text-xl font-bold"> Import</h1>
<Button
variant="link"
className="text-muted-foreground p-0 h-auto"
onClick={() => router.push('/quality/equipment')}
>
</Button>
</div>
{/* 파일 업로드 */}
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<Input
type="file"
accept=".xlsx,.xls,.csv"
onChange={handleFileChange}
/>
<Button onClick={handleUploadPreview} disabled={!file || isUploading} className="w-full">
{isUploading ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<Upload className="h-4 w-4 mr-1" />
)}
</Button>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<FileSpreadsheet className="h-4 w-4 shrink-0" />
<span> 형식: .xlsx, .xls, .csv</span>
</div>
<Button variant="link" size="sm" className="text-blue-600 p-0 h-auto">
<Download className="h-3.5 w-3.5 mr-1" />
</Button>
</CardContent>
</Card>
{/* 미리보기 */}
{previewData.length > 0 && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-base">
({previewData.length})
</CardTitle>
<Button onClick={handleImport} disabled={isImporting}>
{isImporting ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<Upload className="h-4 w-4 mr-1" />
)}
</Button>
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[50px] text-center">No.</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{previewData.map((row, idx) => (
<TableRow key={idx}>
<TableCell className="text-center">{idx + 1}</TableCell>
<TableCell>{row.equipmentCode || '-'}</TableCell>
<TableCell>{row.name || '-'}</TableCell>
<TableCell>{row.equipmentType || '-'}</TableCell>
<TableCell>{row.specification || '-'}</TableCell>
<TableCell>{row.manufacturer || '-'}</TableCell>
<TableCell>{row.location || '-'}</TableCell>
<TableCell>{row.productionLine || '-'}</TableCell>
<TableCell>{row.status || '-'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,588 @@
'use server';
/**
* 설비관리 Server Actions
*
* API Endpoints:
* - GET /api/v1/equipment - 목록 조회
* - POST /api/v1/equipment - 등록
* - GET /api/v1/equipment/{id} - 상세 조회
* - PUT /api/v1/equipment/{id} - 수정
* - DELETE /api/v1/equipment/{id} - 삭제
* - GET /api/v1/equipment/options - 드롭다운 옵션
* - GET /api/v1/equipment/stats - 통계
* - GET /api/v1/equipment/{id}/templates - 점검 템플릿 조회
* - POST /api/v1/equipment/{id}/templates - 점검항목 추가
* - PUT /api/v1/equipment/templates/{id} - 점검항목 수정
* - DELETE /api/v1/equipment/templates/{id} - 점검항목 삭제
* - POST /api/v1/equipment/{id}/templates/copy - 주기 복사
* - GET /api/v1/equipment/inspections - 점검 그리드 데이터
* - PATCH /api/v1/equipment/inspections/toggle - 셀 클릭 토글
* - PATCH /api/v1/equipment/inspections/set-result - 결과 직접 설정
* - DELETE /api/v1/equipment/inspections/reset - 점검 초기화
* - PATCH /api/v1/equipment/inspections/notes - 점검 메모 수정
* - GET /api/v1/equipment/repairs - 수리이력 목록
* - POST /api/v1/equipment/repairs - 수리이력 등록
* - DELETE /api/v1/equipment/repairs/{id} - 수리이력 삭제
*/
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
import { buildApiUrl } from '@/lib/api/query-params';
import type {
EquipmentApiData,
EquipmentPhotoApi,
EquipmentOptionsApi,
EquipmentStatsApi,
EquipmentRepairApi,
InspectionTemplateApi,
Equipment,
EquipmentOptions,
EquipmentStats,
EquipmentRepair,
InspectionTemplate,
EquipmentFormData,
RepairFormData,
InspectionTemplateFormData,
EquipmentStatus,
InspectionCycle,
InspectionResult,
PaginationMeta,
ManagerOption,
} from './types';
// ===== API → Frontend 변환 =====
function transformEquipment(api: EquipmentApiData): Equipment {
return {
id: String(api.id),
equipmentCode: api.equipment_code || '',
name: api.name || '',
equipmentType: api.equipment_type || '',
specification: api.specification || '',
manufacturer: api.manufacturer || '',
modelName: api.model_name || '',
serialNo: api.serial_no || '',
location: api.location || '',
productionLine: api.production_line || '',
purchaseDate: api.purchase_date || '',
installDate: api.install_date || '',
purchasePrice: api.purchase_price || '',
usefulLife: api.useful_life,
status: api.status || 'active',
disposedDate: api.disposed_date || '',
managerId: api.manager_id,
subManagerId: api.sub_manager_id,
managerName: api.manager?.name || '',
subManagerName: api.subManager?.name || '',
memo: api.memo || '',
isActive: api.is_active,
sortOrder: api.sort_order,
photos: (api.photos || []).map((p) => ({
id: p.id,
displayName: p.display_name,
filePath: p.file_path,
fileSize: p.file_size,
mimeType: p.mime_type,
createdAt: p.created_at,
})),
};
}
function transformRepair(api: EquipmentRepairApi): EquipmentRepair {
return {
id: String(api.id),
equipmentId: api.equipment_id,
repairDate: api.repair_date || '',
repairType: api.repair_type,
repairHours: api.repair_hours,
description: api.description || '',
cost: api.cost || '',
vendor: api.vendor || '',
repairedBy: api.repaired_by,
repairerName: api.repairer?.name || '',
memo: api.memo || '',
equipmentCode: api.equipment?.equipment_code || '',
equipmentName: api.equipment?.name || '',
};
}
function transformTemplate(api: InspectionTemplateApi): InspectionTemplate {
return {
id: api.id,
equipmentId: api.equipment_id,
inspectionCycle: api.inspection_cycle,
itemNo: api.item_no || '',
checkPoint: api.check_point || '',
checkItem: api.check_item || '',
checkTiming: api.check_timing || '',
checkFrequency: api.check_frequency || '',
checkMethod: api.check_method || '',
sortOrder: api.sort_order,
isActive: api.is_active,
};
}
function transformOptions(api: EquipmentOptionsApi): EquipmentOptions {
return {
equipmentTypes: api.equipment_types || [],
productionLines: api.production_lines || [],
statuses: api.statuses || {},
equipmentList: (api.equipment_list || []).map((e) => ({
id: e.id,
equipmentCode: e.equipment_code,
name: e.name,
equipmentType: e.equipment_type,
productionLine: e.production_line,
})),
};
}
// ===== Frontend → API 변환 =====
function transformFormToApi(data: EquipmentFormData): Record<string, unknown> {
return {
equipment_code: data.equipmentCode,
name: data.name,
equipment_type: data.equipmentType || null,
specification: data.specification || null,
manufacturer: data.manufacturer || null,
model_name: data.modelName || null,
serial_no: data.serialNo || null,
location: data.location || null,
production_line: data.productionLine || null,
purchase_date: data.purchaseDate || null,
install_date: data.installDate || null,
purchase_price: data.purchasePrice ? Number(data.purchasePrice) : null,
useful_life: data.usefulLife ? Number(data.usefulLife) : null,
status: data.status || 'active',
manager_id: data.managerId ? Number(data.managerId) : null,
sub_manager_id: data.subManagerId ? Number(data.subManagerId) : null,
memo: data.memo || null,
};
}
function transformRepairFormToApi(data: RepairFormData): Record<string, unknown> {
return {
equipment_id: Number(data.equipmentId),
repair_date: data.repairDate,
repair_type: data.repairType || null,
repair_hours: data.repairHours ? Number(data.repairHours) : null,
description: data.description || null,
cost: data.cost ? Number(data.cost) : null,
vendor: data.vendor || null,
repaired_by: data.repairedBy ? Number(data.repairedBy) : null,
memo: data.memo || null,
};
}
// ===== 설비 CRUD =====
interface PaginatedEquipmentResponse {
data: EquipmentApiData[];
current_page: number;
last_page: number;
per_page: number;
total: number;
}
export async function getEquipmentList(params?: {
page?: number;
perPage?: number;
search?: string;
status?: EquipmentStatus | 'all';
productionLine?: string;
equipmentType?: string;
}): Promise<{
success: boolean;
data: Equipment[];
pagination: PaginationMeta;
error?: string;
__authError?: boolean;
}> {
const defaultPagination: PaginationMeta = { currentPage: 1, lastPage: 1, perPage: 20, total: 0 };
const result = await executeServerAction<PaginatedEquipmentResponse>({
url: buildApiUrl('/api/v1/equipment', {
page: params?.page,
per_page: params?.perPage || 20,
search: params?.search,
status: params?.status !== 'all' ? params?.status : undefined,
production_line: params?.productionLine !== 'all' ? params?.productionLine : undefined,
equipment_type: params?.equipmentType !== 'all' ? params?.equipmentType : undefined,
}),
errorMessage: '설비 목록 조회에 실패했습니다.',
});
if (!result.success) {
return { success: false, data: [], pagination: defaultPagination, error: result.error, __authError: result.__authError };
}
const d = result.data;
return {
success: true,
data: (d?.data || []).map(transformEquipment),
pagination: {
currentPage: d?.current_page || 1,
lastPage: d?.last_page || 1,
perPage: d?.per_page || 20,
total: d?.total || 0,
},
};
}
export async function getEquipmentDetail(id: string): Promise<ActionResult<Equipment>> {
const result = await executeServerAction<EquipmentApiData>({
url: buildApiUrl(`/api/v1/equipment/${id}`),
errorMessage: '설비 상세 조회에 실패했습니다.',
});
if (!result.success) return { success: false, error: result.error, __authError: result.__authError };
return result.data
? { success: true, data: transformEquipment(result.data) }
: { success: false, error: '설비 데이터를 찾을 수 없습니다.' };
}
export async function createEquipment(data: EquipmentFormData): Promise<ActionResult<Equipment>> {
const result = await executeServerAction<EquipmentApiData>({
url: buildApiUrl('/api/v1/equipment'),
method: 'POST',
body: transformFormToApi(data),
errorMessage: '설비 등록에 실패했습니다.',
});
if (!result.success) return { success: false, error: result.error, fieldErrors: result.fieldErrors, __authError: result.__authError };
return result.data
? { success: true, data: transformEquipment(result.data) }
: { success: true };
}
export async function updateEquipment(id: string, data: EquipmentFormData): Promise<ActionResult<Equipment>> {
const result = await executeServerAction<EquipmentApiData>({
url: buildApiUrl(`/api/v1/equipment/${id}`),
method: 'PUT',
body: transformFormToApi(data),
errorMessage: '설비 수정에 실패했습니다.',
});
if (!result.success) return { success: false, error: result.error, fieldErrors: result.fieldErrors, __authError: result.__authError };
return result.data
? { success: true, data: transformEquipment(result.data) }
: { success: true };
}
export async function deleteEquipment(id: string): Promise<ActionResult> {
return executeServerAction({
url: buildApiUrl(`/api/v1/equipment/${id}`),
method: 'DELETE',
errorMessage: '설비 삭제에 실패했습니다.',
});
}
// ===== 옵션 / 통계 =====
export async function getEquipmentOptions(): Promise<ActionResult<EquipmentOptions>> {
const result = await executeServerAction<EquipmentOptionsApi>({
url: buildApiUrl('/api/v1/equipment/options'),
errorMessage: '설비 옵션 조회에 실패했습니다.',
});
if (!result.success) return { success: false, error: result.error, __authError: result.__authError };
return result.data
? { success: true, data: transformOptions(result.data) }
: { success: false, error: '옵션 데이터를 찾을 수 없습니다.' };
}
export async function getEquipmentStats(): Promise<ActionResult<EquipmentStats>> {
const result = await executeServerAction<EquipmentStatsApi>({
url: buildApiUrl('/api/v1/equipment/stats'),
errorMessage: '설비 통계 조회에 실패했습니다.',
});
if (!result.success) return { success: false, error: result.error, __authError: result.__authError };
if (!result.data) return { success: false, error: '통계 데이터를 찾을 수 없습니다.' };
const d = result.data;
return {
success: true,
data: {
total: d.total,
active: d.active,
idle: d.idle,
disposed: d.disposed,
inspectionStats: d.inspection_stats
? {
targetCount: d.inspection_stats.target_count,
completedCount: d.inspection_stats.completed_count,
issueCount: d.inspection_stats.issue_count,
}
: undefined,
typeDistribution: d.type_distribution
? d.type_distribution.map((t) => ({
equipmentType: t.equipment_type,
count: t.count,
}))
: undefined,
},
};
}
// ===== 점검 템플릿 =====
export async function getInspectionTemplates(
equipmentId: string,
cycle?: InspectionCycle
): Promise<ActionResult<InspectionTemplate[]>> {
const result = await executeServerAction<InspectionTemplateApi[]>({
url: buildApiUrl(`/api/v1/equipment/${equipmentId}/templates`, {
cycle,
}),
errorMessage: '점검항목 조회에 실패했습니다.',
});
if (!result.success) return { success: false, error: result.error, __authError: result.__authError };
// API가 객체 배열이 아닌 경우 방어 (예: 문자열 배열 반환 시)
const rawData = result.data || [];
const validData = rawData.filter((item): item is InspectionTemplateApi => typeof item === 'object' && item !== null && 'id' in item);
return {
success: true,
data: validData.map(transformTemplate),
};
}
export async function createInspectionTemplate(
equipmentId: string,
data: InspectionTemplateFormData
): Promise<ActionResult<InspectionTemplate>> {
const result = await executeServerAction<InspectionTemplateApi>({
url: buildApiUrl(`/api/v1/equipment/${equipmentId}/templates`),
method: 'POST',
body: {
inspection_cycle: data.inspectionCycle,
item_no: data.itemNo,
check_point: data.checkPoint,
check_item: data.checkItem,
check_timing: data.checkTiming || null,
check_frequency: data.checkFrequency || null,
check_method: data.checkMethod || null,
},
errorMessage: '점검항목 추가에 실패했습니다.',
});
if (!result.success) return { success: false, error: result.error, fieldErrors: result.fieldErrors, __authError: result.__authError };
return result.data
? { success: true, data: transformTemplate(result.data) }
: { success: true };
}
export async function deleteInspectionTemplate(templateId: number): Promise<ActionResult> {
return executeServerAction({
url: buildApiUrl(`/api/v1/equipment/templates/${templateId}`),
method: 'DELETE',
errorMessage: '점검항목 삭제에 실패했습니다.',
});
}
export async function copyInspectionTemplates(
equipmentId: string,
sourceCycle: InspectionCycle,
targetCycles: InspectionCycle[]
): Promise<ActionResult<{ copied: number; skipped: number }>> {
return executeServerAction({
url: buildApiUrl(`/api/v1/equipment/${equipmentId}/templates/copy`),
method: 'POST',
body: {
source_cycle: sourceCycle,
target_cycles: targetCycles,
},
errorMessage: '점검항목 복사에 실패했습니다.',
});
}
// ===== 점검 그리드 =====
export async function getInspectionGrid(params?: {
cycle?: InspectionCycle;
period?: string;
productionLine?: string;
equipmentId?: number;
}): Promise<ActionResult<unknown>> {
return executeServerAction({
url: buildApiUrl('/api/v1/equipment/inspections', {
cycle: params?.cycle || 'daily',
period: params?.period,
production_line: params?.productionLine !== 'all' ? params?.productionLine : undefined,
equipment_id: params?.equipmentId,
}),
errorMessage: '점검 데이터 조회에 실패했습니다.',
});
}
export async function toggleInspectionResult(params: {
equipmentId: number;
templateItemId: number;
checkDate: string;
cycle?: InspectionCycle;
}): Promise<ActionResult<{ result: InspectionResult; symbol: string }>> {
return executeServerAction({
url: buildApiUrl('/api/v1/equipment/inspections/toggle'),
method: 'PATCH',
body: {
equipment_id: params.equipmentId,
template_item_id: params.templateItemId,
check_date: params.checkDate,
cycle: params.cycle || 'daily',
},
errorMessage: '점검 결과 변경에 실패했습니다.',
});
}
export async function resetInspections(params: {
equipmentId?: number;
cycle?: InspectionCycle;
period?: string;
}): Promise<ActionResult<{ deleted_count: number }>> {
return executeServerAction({
url: buildApiUrl('/api/v1/equipment/inspections/reset'),
method: 'DELETE',
body: {
equipment_id: params.equipmentId,
cycle: params.cycle || 'daily',
period: params.period,
},
errorMessage: '점검 초기화에 실패했습니다.',
});
}
// ===== 수리이력 =====
interface PaginatedRepairResponse {
data: EquipmentRepairApi[];
current_page: number;
last_page: number;
per_page: number;
total: number;
}
export async function getRepairList(params?: {
page?: number;
perPage?: number;
search?: string;
equipmentId?: string;
repairType?: string;
dateFrom?: string;
dateTo?: string;
}): Promise<{
success: boolean;
data: EquipmentRepair[];
pagination: PaginationMeta;
error?: string;
__authError?: boolean;
}> {
const defaultPagination: PaginationMeta = { currentPage: 1, lastPage: 1, perPage: 20, total: 0 };
const result = await executeServerAction<PaginatedRepairResponse>({
url: buildApiUrl('/api/v1/equipment/repairs', {
page: params?.page,
per_page: params?.perPage || 20,
search: params?.search,
equipment_id: params?.equipmentId !== 'all' ? params?.equipmentId : undefined,
repair_type: params?.repairType !== 'all' ? params?.repairType : undefined,
date_from: params?.dateFrom,
date_to: params?.dateTo,
}),
errorMessage: '수리이력 목록 조회에 실패했습니다.',
});
if (!result.success) {
return { success: false, data: [], pagination: defaultPagination, error: result.error, __authError: result.__authError };
}
const d = result.data;
return {
success: true,
data: (d?.data || []).map(transformRepair),
pagination: {
currentPage: d?.current_page || 1,
lastPage: d?.last_page || 1,
perPage: d?.per_page || 20,
total: d?.total || 0,
},
};
}
export async function createRepair(data: RepairFormData): Promise<ActionResult<EquipmentRepair>> {
const result = await executeServerAction<EquipmentRepairApi>({
url: buildApiUrl('/api/v1/equipment/repairs'),
method: 'POST',
body: transformRepairFormToApi(data),
errorMessage: '수리이력 등록에 실패했습니다.',
});
if (!result.success) return { success: false, error: result.error, fieldErrors: result.fieldErrors, __authError: result.__authError };
return result.data
? { success: true, data: transformRepair(result.data) }
: { success: true };
}
export async function deleteRepair(id: string): Promise<ActionResult> {
return executeServerAction({
url: buildApiUrl(`/api/v1/equipment/repairs/${id}`),
method: 'DELETE',
errorMessage: '수리이력 삭제에 실패했습니다.',
});
}
// ===== 설비 사진 =====
export async function uploadEquipmentPhoto(equipmentId: string, file: File): Promise<ActionResult<EquipmentPhotoApi>> {
const formData = new FormData();
formData.append('file', file);
return executeServerAction<EquipmentPhotoApi>({
url: buildApiUrl(`/api/v1/equipment/${equipmentId}/photos`),
method: 'POST',
body: formData,
errorMessage: '사진 업로드에 실패했습니다.',
});
}
export async function deleteEquipmentPhoto(equipmentId: string, fileId: number): Promise<ActionResult> {
return executeServerAction({
url: buildApiUrl(`/api/v1/equipment/${equipmentId}/photos/${fileId}`),
method: 'DELETE',
errorMessage: '사진 삭제에 실패했습니다.',
});
}
// ===== 직원 목록 (관리자 선택용) =====
interface EmployeeApiData {
user_id?: number;
user?: { id: number; name: string };
name?: string;
department?: { name: string };
tenant_user_profile?: { department?: { name: string }; position?: { name: string } };
position_key?: string;
}
interface PaginatedEmployeeResponse {
data: EmployeeApiData[];
}
export async function getManagerOptions(): Promise<ManagerOption[]> {
const result = await executeServerAction<PaginatedEmployeeResponse>({
url: buildApiUrl('/api/v1/employees', { per_page: 100, status: 'active' }),
errorMessage: '직원 목록 조회에 실패했습니다.',
});
if (!result.success || !result.data?.data) return [];
return result.data.data
.map((emp) => ({
id: String(emp.user?.id || emp.user_id),
name: emp.user?.name || emp.name || '',
department: emp.department?.name || emp.tenant_user_profile?.department?.name || '',
position: emp.position_key || emp.tenant_user_profile?.position?.name || '',
}))
.filter((emp) => emp.name && emp.id && emp.id !== 'undefined');
}

View File

@@ -0,0 +1,343 @@
'use client';
/**
* 설비 등록대장 - 목록 페이지
*
* 필터: 검색(설비번호/설비명), 상태, 라인, 유형
* 테이블: 설비번호, 설비명, 유형, 위치, 생산라인, 상태, 관리자 정/부, QR, 액션
* 액션: 엑셀 Import, 설비 등록
*/
import { useState, useEffect, useMemo, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import {
Wrench,
Plus,
FileUp,
SquarePen,
Trash2,
} from 'lucide-react';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { TableCell, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
import {
UniversalListPage,
type UniversalListConfig,
type SelectionHandlers,
type RowClickHandlers,
type ListParams,
type FilterFieldConfig,
} from '@/components/templates/UniversalListPage';
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import {
getEquipmentList,
getEquipmentOptions,
deleteEquipment,
} from './actions';
import type { Equipment, EquipmentOptions, EquipmentStatus } from './types';
import {
EQUIPMENT_STATUS_LABEL,
EQUIPMENT_STATUS_COLOR,
} from './types';
const ITEMS_PER_PAGE = 20;
export function EquipmentManagement() {
const router = useRouter();
// ===== 필터 옵션 (동적) =====
const [options, setOptions] = useState<EquipmentOptions | null>(null);
// ===== 삭제 확인 =====
const [deleteTarget, setDeleteTarget] = useState<Equipment | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
// ===== 옵션 로드 =====
useEffect(() => {
getEquipmentOptions().then((result) => {
if (result.success && result.data) {
setOptions(result.data);
}
});
}, []);
// ===== filterConfig (공용 모바일 필터 지원) =====
const filterConfig: FilterFieldConfig[] = useMemo(() => [
{
key: 'status',
label: '상태',
type: 'single' as const,
options: [
{ value: 'active', label: '가동' },
{ value: 'idle', label: '유휴' },
{ value: 'disposed', label: '폐기' },
],
allOptionLabel: '상태 전체',
},
{
key: 'productionLine',
label: '라인',
type: 'single' as const,
options: (options?.productionLines || []).map((line) => ({
value: line,
label: line,
})),
allOptionLabel: '라인 전체',
},
{
key: 'equipmentType',
label: '유형',
type: 'single' as const,
options: (options?.equipmentTypes || []).map((type) => ({
value: type,
label: type,
})),
allOptionLabel: '유형 전체',
},
], [options]);
// ===== 행 클릭 =====
const handleRowClick = useCallback(
(item: Equipment) => {
router.push(`/quality/equipment/${item.id}`);
},
[router]
);
// ===== 삭제 =====
const handleDeleteClick = useCallback((e: React.MouseEvent, item: Equipment) => {
e.stopPropagation();
setDeleteTarget(item);
}, []);
const handleDeleteConfirm = useCallback(async () => {
if (!deleteTarget) return;
setIsDeleting(true);
try {
const result = await deleteEquipment(deleteTarget.id);
if (result.success) {
toast.success('설비가 삭제되었습니다.');
setRefreshKey((prev) => prev + 1);
} else {
toast.error(result.error || '삭제에 실패했습니다.');
}
} finally {
setIsDeleting(false);
setDeleteTarget(null);
}
}, [deleteTarget]);
// ===== UniversalListPage Config =====
const config: UniversalListConfig<Equipment> = useMemo(
() => ({
title: '설비 등록대장',
description: '설비 정보를 관리합니다',
icon: Wrench,
basePath: '/quality/equipment',
idField: 'id',
// API 액션
actions: {
getList: async (params?: ListParams) => {
const filters = params?.filters || {};
const result = await getEquipmentList({
page: params?.page || 1,
perPage: params?.pageSize || ITEMS_PER_PAGE,
search: params?.search || undefined,
status: (filters.status as EquipmentStatus) || undefined,
productionLine: (filters.productionLine as string) || undefined,
equipmentType: (filters.equipmentType as string) || undefined,
});
if (result.success) {
return {
success: true,
data: result.data,
totalCount: result.pagination.total,
totalPages: result.pagination.lastPage,
};
}
return { success: false, error: result.error };
},
},
// 헤더 액션 버튼 (함수 형태)
headerActions: () => (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => router.push('/quality/equipment/import')}
>
<FileUp className="h-4 w-4 mr-1" />
Import
</Button>
<Button
size="sm"
onClick={() => router.push('/quality/equipment?mode=new')}
>
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
),
// 필터 (공용 MobileFilter 연동)
filterConfig,
filterTitle: '설비 필터',
// 테이블 컬럼
columns: [
{ key: 'no', label: 'No.', className: 'w-[50px] text-center' },
{ key: 'equipmentCode', label: '설비번호', className: 'min-w-[110px]', copyable: true },
{ key: 'name', label: '설비명', className: 'min-w-[100px]', copyable: true },
{ key: 'equipmentType', label: '유형', className: 'w-[80px] text-center' },
{ key: 'location', label: '위치', className: 'w-[80px] text-center' },
{ key: 'productionLine', label: '생산라인', className: 'w-[80px] text-center' },
{ key: 'status', label: '상태', className: 'w-[70px] text-center' },
{ key: 'managerName', label: '관리자 정', className: 'w-[80px] text-center' },
{ key: 'subManagerName', label: '관리자 부', className: 'w-[80px] text-center' },
{ key: 'purchaseDate', label: '구입일', className: 'w-[100px] text-center' },
{ key: 'qr', label: 'QR', className: 'w-[60px] text-center' },
{ key: 'actions', label: '액션', className: 'w-[100px] text-center' },
],
// 서버 사이드 페이지네이션
clientSideFiltering: false,
itemsPerPage: ITEMS_PER_PAGE,
// 검색 + 필터 (hideSearch: false → 검색 카드 안에 extraFilters 표시)
hideSearch: false,
searchPlaceholder: '설비번호/설비명 검색...',
// 테이블 행 렌더링
renderTableRow: (
item: Equipment,
index: number,
globalIndex: number,
handlers: SelectionHandlers & RowClickHandlers<Equipment>
) => (
<TableRow
key={item.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => handlers.onRowClick?.()}
>
<TableCell className="text-center">
<Checkbox
checked={handlers.isSelected}
onCheckedChange={() => handlers.onToggle()}
onClick={(e) => e.stopPropagation()}
/>
</TableCell>
<TableCell className="text-center">{globalIndex}</TableCell>
<TableCell className="font-medium text-blue-600">{item.equipmentCode}</TableCell>
<TableCell>{item.name}</TableCell>
<TableCell className="text-center">{item.equipmentType || '-'}</TableCell>
<TableCell className="text-center">{item.location || '-'}</TableCell>
<TableCell className="text-center">{item.productionLine || '-'}</TableCell>
<TableCell className="text-center">
<Badge className={EQUIPMENT_STATUS_COLOR[item.status] || ''} variant="secondary">
{EQUIPMENT_STATUS_LABEL[item.status] || item.status}
</Badge>
</TableCell>
<TableCell className="text-center">{item.managerName || '-'}</TableCell>
<TableCell className="text-center">{item.subManagerName || '-'}</TableCell>
<TableCell className="text-center">{item.purchaseDate || '-'}</TableCell>
<TableCell className="text-center">-</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-blue-600 hover:text-blue-800"
onClick={(e) => {
e.stopPropagation();
router.push(`/quality/equipment/${item.id}?mode=edit`);
}}
>
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-red-600 hover:text-red-800"
onClick={(e) => handleDeleteClick(e, item)}
>
</Button>
</div>
</TableCell>
</TableRow>
),
// 모바일 카드 렌더링
renderMobileCard: (
item: Equipment,
index: number,
globalIndex: number,
handlers: SelectionHandlers & RowClickHandlers<Equipment>
) => (
<ListMobileCard
key={item.id}
title={`${item.equipmentCode} ${item.name}`}
headerBadges={
<Badge className={EQUIPMENT_STATUS_COLOR[item.status]} variant="secondary">
{EQUIPMENT_STATUS_LABEL[item.status]}
</Badge>
}
infoGrid={
<div className="grid grid-cols-2 gap-2">
<InfoField label="유형" value={item.equipmentType || '-'} />
<InfoField label="위치" value={item.location || '-'} />
<InfoField label="생산라인" value={item.productionLine || '-'} />
<InfoField label="관리자" value={item.managerName || '-'} />
</div>
}
showCheckbox
isSelected={handlers.isSelected}
onToggleSelection={() => handlers.onToggle()}
onClick={() => handlers.onRowClick?.()}
actions={[
{
label: '수정',
icon: SquarePen,
onClick: () => router.push(`/quality/equipment/${item.id}?mode=edit`),
},
{
label: '삭제',
icon: Trash2,
variant: 'destructive' as const,
onClick: () => setDeleteTarget(item),
},
]}
/>
),
// 행 클릭
onRowClick: handleRowClick,
}),
[filterConfig, handleRowClick, handleDeleteClick, router]
);
return (
<>
<UniversalListPage
key={refreshKey}
config={config}
/>
<DeleteConfirmDialog
open={!!deleteTarget}
onOpenChange={(open: boolean) => !open && setDeleteTarget(null)}
itemName={deleteTarget?.name}
description={`"${deleteTarget?.name}" 설비를 삭제하시겠습니까?`}
loading={isDeleting}
onConfirm={handleDeleteConfirm}
/>
</>
);
}

View File

@@ -0,0 +1,352 @@
// 설비 등록대장 타입 정의
// ===== 설비 상태 =====
export type EquipmentStatus = 'active' | 'idle' | 'disposed';
export const EQUIPMENT_STATUS_LABEL: Record<EquipmentStatus, string> = {
active: '가동',
idle: '유휴',
disposed: '폐기',
};
export const EQUIPMENT_STATUS_COLOR: Record<EquipmentStatus, string> = {
active: 'bg-green-100 text-green-700',
idle: 'bg-yellow-100 text-yellow-700',
disposed: 'bg-red-100 text-red-700',
};
// ===== 점검 주기 =====
export type InspectionCycle = 'daily' | 'weekly' | 'monthly' | 'bimonthly' | 'quarterly' | 'semiannual';
export const INSPECTION_CYCLE_LABEL: Record<InspectionCycle, string> = {
daily: '일일',
weekly: '주간',
monthly: '월간',
bimonthly: '2개월',
quarterly: '분기',
semiannual: '반년',
};
export const INSPECTION_CYCLES: InspectionCycle[] = [
'daily', 'weekly', 'monthly', 'bimonthly', 'quarterly', 'semiannual',
];
// ===== 점검 결과 =====
export type InspectionResult = 'good' | 'bad' | 'repaired' | null;
export const INSPECTION_RESULT_SYMBOL: Record<string, string> = {
good: '○',
bad: 'X',
repaired: '△',
};
// ===== 보전 구분 =====
export type RepairType = 'internal' | 'external';
export const REPAIR_TYPE_LABEL: Record<RepairType, string> = {
internal: '사내',
external: '외주',
};
export const REPAIR_TYPE_COLOR: Record<RepairType, string> = {
internal: 'bg-yellow-100 text-yellow-700',
external: 'bg-blue-100 text-blue-700',
};
// ===== API 응답 타입 =====
export interface EquipmentApiData {
id: number;
tenant_id: number;
equipment_code: string;
name: string;
equipment_type: string | null;
specification: string | null;
manufacturer: string | null;
model_name: string | null;
serial_no: string | null;
location: string | null;
production_line: string | null;
purchase_date: string | null;
install_date: string | null;
purchase_price: string | null;
useful_life: number | null;
status: EquipmentStatus;
disposed_date: string | null;
manager_id: number | null;
sub_manager_id: number | null;
memo: string | null;
is_active: boolean;
sort_order: number;
manager: { id: number; name: string } | null;
subManager: { id: number; name: string } | null;
photos?: EquipmentPhotoApi[];
created_at?: string;
updated_at?: string;
}
export interface EquipmentPhotoApi {
id: number;
display_name: string;
stored_name: string;
file_path: string;
file_size: number;
mime_type: string;
created_at: string;
}
export interface InspectionTemplateApi {
id: number;
equipment_id: number;
inspection_cycle: InspectionCycle;
item_no: string;
check_point: string;
check_item: string;
check_timing: string | null;
check_frequency: string | null;
check_method: string | null;
sort_order: number;
is_active: boolean;
}
export interface EquipmentRepairApi {
id: number;
tenant_id: number;
equipment_id: number;
repair_date: string;
repair_type: RepairType | null;
repair_hours: number | null;
description: string | null;
cost: string | null;
vendor: string | null;
repaired_by: number | null;
memo: string | null;
equipment: { id: number; equipment_code: string; name: string } | null;
repairer: { id: number; name: string } | null;
created_at?: string;
}
export interface EquipmentOptionsApi {
equipment_types: string[];
production_lines: string[];
statuses: Record<EquipmentStatus, string>;
equipment_list: Array<{
id: number;
equipment_code: string;
name: string;
equipment_type: string;
production_line: string;
}>;
}
export interface EquipmentStatsApi {
total: number;
active: number;
idle: number;
disposed: number;
inspection_stats?: {
target_count: number;
completed_count: number;
issue_count: number;
};
type_distribution?: Array<{
equipment_type: string;
count: number;
}>;
}
// ===== 프론트엔드 타입 =====
export interface Equipment {
id: string;
equipmentCode: string;
name: string;
equipmentType: string;
specification: string;
manufacturer: string;
modelName: string;
serialNo: string;
location: string;
productionLine: string;
purchaseDate: string;
installDate: string;
purchasePrice: string;
usefulLife: number | null;
status: EquipmentStatus;
disposedDate: string;
managerId: number | null;
subManagerId: number | null;
managerName: string;
subManagerName: string;
memo: string;
isActive: boolean;
sortOrder: number;
photos: EquipmentPhoto[];
}
export interface EquipmentPhoto {
id: number;
displayName: string;
filePath: string;
fileSize: number;
mimeType: string;
createdAt: string;
}
export interface InspectionTemplate {
id: number;
equipmentId: number;
inspectionCycle: InspectionCycle;
itemNo: string;
checkPoint: string;
checkItem: string;
checkTiming: string;
checkFrequency: string;
checkMethod: string;
sortOrder: number;
isActive: boolean;
}
export interface EquipmentRepair {
id: string;
equipmentId: number;
repairDate: string;
repairType: RepairType | null;
repairHours: number | null;
description: string;
cost: string;
vendor: string;
repairedBy: number | null;
repairerName: string;
memo: string;
equipmentCode: string;
equipmentName: string;
}
export interface EquipmentOptions {
equipmentTypes: string[];
productionLines: string[];
statuses: Record<EquipmentStatus, string>;
equipmentList: Array<{
id: number;
equipmentCode: string;
name: string;
equipmentType: string;
productionLine: string;
}>;
}
export interface EquipmentStats {
total: number;
active: number;
idle: number;
disposed: number;
inspectionStats?: {
targetCount: number;
completedCount: number;
issueCount: number;
};
typeDistribution?: Array<{
equipmentType: string;
count: number;
}>;
}
// ===== 폼 데이터 =====
export interface EquipmentFormData {
equipmentCode: string;
name: string;
equipmentType: string;
specification: string;
manufacturer: string;
modelName: string;
serialNo: string;
location: string;
productionLine: string;
purchaseDate: string;
installDate: string;
purchasePrice: string;
usefulLife: string;
status: EquipmentStatus;
managerId: string;
subManagerId: string;
memo: string;
}
export interface RepairFormData {
equipmentId: string;
repairDate: string;
repairType: string;
repairHours: string;
description: string;
cost: string;
vendor: string;
repairedBy: string;
memo: string;
}
export interface InspectionTemplateFormData {
inspectionCycle: InspectionCycle;
itemNo: string;
checkPoint: string;
checkItem: string;
checkTiming: string;
checkFrequency: string;
checkMethod: string;
}
// ===== 점검 그리드 =====
export interface InspectionGridRow {
equipment: {
id: number;
equipmentCode: string;
name: string;
};
templates: InspectionTemplate[];
details: Record<string, InspectionResult>; // key: "{templateId}_{date}"
canInspect: boolean;
overallJudgment: string | null;
}
export interface InspectionGridData {
rows: InspectionGridRow[];
labels: string[]; // 날짜 라벨 배열
nonWorkingDays: string[]; // 주말/공휴일 날짜
}
// ===== 직원 옵션 (관리자 선택용) =====
export interface ManagerOption {
id: string;
name: string;
department: string;
position: string;
}
// ===== 페이지네이션 =====
export interface PaginationMeta {
currentPage: number;
lastPage: number;
perPage: number;
total: number;
}
// ===== 필터 =====
export interface EquipmentFilter {
search: string;
status: EquipmentStatus | 'all';
productionLine: string;
equipmentType: string;
}
export interface RepairFilter {
search: string;
equipmentId: string;
repairType: string;
dateFrom: string;
dateTo: string;
}

View File

@@ -0,0 +1,268 @@
'use client';
/**
* 수리이력 등록/수정 폼
* URL: /quality/equipment-repairs?mode=new
*/
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { Loader2, Save, X, Zap } from 'lucide-react';
import { toast } from 'sonner';
import { useMenuStore } from '@/stores/menuStore';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { FormField } from '@/components/molecules/FormField';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
createRepair,
getEquipmentOptions,
getManagerOptions,
} from '@/components/quality/EquipmentManagement/actions';
import type {
RepairFormData,
EquipmentOptions,
ManagerOption,
} from '@/components/quality/EquipmentManagement/types';
import { REPAIR_TYPE_LABEL } from '@/components/quality/EquipmentManagement/types';
const initialFormData: RepairFormData = {
equipmentId: '',
repairDate: '',
repairType: '',
repairHours: '',
description: '',
cost: '',
vendor: '',
repairedBy: '',
memo: '',
};
export function RepairForm() {
const router = useRouter();
const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed);
const listPath = '/quality/equipment-repairs';
const [formData, setFormData] = useState<RepairFormData>(initialFormData);
const [options, setOptions] = useState<EquipmentOptions | null>(null);
const [managers, setManagers] = useState<ManagerOption[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
Promise.all([
getEquipmentOptions(),
getManagerOptions(),
]).then(([optResult, mgrResult]) => {
if (optResult.success && optResult.data) {
setOptions(optResult.data);
}
setManagers(mgrResult);
});
}, []);
const handleChange = useCallback((field: keyof RepairFormData, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
}, []);
const handleSubmit = useCallback(async () => {
if (!formData.equipmentId) {
toast.error('설비를 선택하세요.');
return;
}
if (!formData.repairDate) {
toast.error('수리일을 입력하세요.');
return;
}
if (!formData.repairType) {
toast.error('보전구분을 선택하세요.');
return;
}
setIsSubmitting(true);
try {
const result = await createRepair(formData);
if (result.success) {
toast.success('수리이력이 등록되었습니다.');
router.push(listPath);
} else {
toast.error(result.error || '등록에 실패했습니다.');
}
} finally {
setIsSubmitting(false);
}
}, [formData, router, listPath]);
return (
<div className="space-y-6 p-3 md:p-6">
{/* 헤더 */}
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold flex items-center gap-1">
<Zap className="h-5 w-5 text-yellow-500" />
</h1>
<Button
variant="link"
className="text-muted-foreground"
onClick={() => router.push(listPath)}
>
</Button>
</div>
{/* 폼 */}
<Card>
<CardContent className="pt-6 space-y-6">
{/* Row 1: 설비 | 수리일 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label> <span className="text-red-500">*</span></Label>
<div className="mt-1">
<Select
value={formData.equipmentId || '_none'}
onValueChange={(v) => handleChange('equipmentId', v === '_none' ? '' : v)}
>
<SelectTrigger>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_none"></SelectItem>
{(options?.equipmentList || []).map((eq) => (
<SelectItem key={eq.id} value={String(eq.id)}>
{eq.equipmentCode} {eq.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<FormField
label="수리일"
type="date"
required
value={formData.repairDate}
onChange={(v) => handleChange('repairDate', v)}
/>
</div>
{/* Row 2: 보전구분 | 수리시간 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label> <span className="text-red-500">*</span></Label>
<div className="mt-1">
<Select
value={formData.repairType || '_none'}
onValueChange={(v) => handleChange('repairType', v === '_none' ? '' : v)}
>
<SelectTrigger>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_none"></SelectItem>
{Object.entries(REPAIR_TYPE_LABEL).map(([val, label]) => (
<SelectItem key={val} value={val}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<FormField
label="수리시간 (h)"
type="number"
value={formData.repairHours}
onChange={(v) => handleChange('repairHours', v)}
/>
</div>
{/* Row 3: 수리내용 */}
<div>
<Label></Label>
<div className="mt-1">
<Textarea
value={formData.description}
onChange={(e) => handleChange('description', e.target.value)}
placeholder="수리 내용을 입력하세요"
rows={4}
/>
</div>
</div>
{/* Row 4: 수리비용 | 외주업체 | 수리자 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<FormField
label="수리비용 (원)"
type="number"
value={formData.cost}
onChange={(v) => handleChange('cost', v)}
/>
<FormField
label="외주업체"
value={formData.vendor}
onChange={(v) => handleChange('vendor', v)}
/>
<div>
<Label></Label>
<div className="mt-1">
<Select
value={formData.repairedBy || '_none'}
onValueChange={(v) => handleChange('repairedBy', v === '_none' ? '' : v)}
>
<SelectTrigger>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_none"></SelectItem>
{managers.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.name} ({m.department})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
{/* Row 5: 비고 */}
<div>
<Label></Label>
<div className="mt-1">
<Textarea
value={formData.memo}
onChange={(e) => handleChange('memo', e.target.value)}
placeholder="비고를 입력하세요"
rows={3}
/>
</div>
</div>
</CardContent>
</Card>
{/* 하단 버튼 (sticky 하단 바) */}
<div className={`fixed bottom-4 left-4 right-4 px-4 py-3 bg-background/95 backdrop-blur rounded-xl border shadow-lg z-50 transition-all duration-300 md:bottom-6 md:px-6 md:right-[48px] ${sidebarCollapsed ? 'md:left-[156px]' : 'md:left-[316px]'} flex items-center justify-between`}>
<Button
variant="outline"
onClick={() => router.push(listPath)}
disabled={isSubmitting}
>
<X className="h-4 w-4 mr-1" />
</Button>
<Button onClick={handleSubmit} disabled={isSubmitting}>
{isSubmitting ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <Save className="h-4 w-4 mr-1" />}
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,284 @@
'use client';
/**
* 수리이력 목록 페이지
*/
import { useState, useEffect, useMemo, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import {
Wrench,
Plus,
Trash2,
} from 'lucide-react';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { TableCell, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
import {
UniversalListPage,
type UniversalListConfig,
type SelectionHandlers,
type RowClickHandlers,
type ListParams,
type FilterFieldConfig,
} from '@/components/templates/UniversalListPage';
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import {
getRepairList,
deleteRepair,
getEquipmentOptions,
} from '@/components/quality/EquipmentManagement/actions';
import type {
EquipmentRepair,
EquipmentOptions,
} from '@/components/quality/EquipmentManagement/types';
import {
REPAIR_TYPE_LABEL,
REPAIR_TYPE_COLOR,
} from '@/components/quality/EquipmentManagement/types';
const ITEMS_PER_PAGE = 20;
export function RepairList() {
const router = useRouter();
const [dateFrom, setDateFrom] = useState<string>('');
const [dateTo, setDateTo] = useState<string>('');
const [options, setOptions] = useState<EquipmentOptions | null>(null);
const [deleteTarget, setDeleteTarget] = useState<EquipmentRepair | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
useEffect(() => {
getEquipmentOptions().then((result) => {
if (result.success && result.data) {
setOptions(result.data);
}
});
}, []);
// ===== filterConfig (공용 모바일 필터 지원) =====
const filterConfig: FilterFieldConfig[] = useMemo(() => [
{
key: 'equipmentId',
label: '설비',
type: 'single' as const,
options: (options?.equipmentList || []).map((eq) => ({
value: String(eq.id),
label: `${eq.equipmentCode} ${eq.name}`,
})),
allOptionLabel: '설비 전체',
},
{
key: 'repairType',
label: '보전구분',
type: 'single' as const,
options: [
{ value: 'internal', label: '사내' },
{ value: 'external', label: '외주' },
],
allOptionLabel: '구분 전체',
},
], [options]);
const handleDeleteClick = useCallback((e: React.MouseEvent, item: EquipmentRepair) => {
e.stopPropagation();
setDeleteTarget(item);
}, []);
const handleDeleteConfirm = useCallback(async () => {
if (!deleteTarget) return;
setIsDeleting(true);
try {
const result = await deleteRepair(deleteTarget.id);
if (result.success) {
toast.success('수리이력이 삭제되었습니다.');
setRefreshKey((prev) => prev + 1);
} else {
toast.error(result.error || '삭제에 실패했습니다.');
}
} finally {
setIsDeleting(false);
setDeleteTarget(null);
}
}, [deleteTarget]);
const config: UniversalListConfig<EquipmentRepair> = useMemo(
() => ({
title: '수리이력',
description: '설비 수리이력을 관리합니다',
icon: Wrench,
basePath: '/quality/equipment-repairs',
idField: 'id',
actions: {
getList: async (params?: ListParams) => {
const filters = params?.filters || {};
const result = await getRepairList({
page: params?.page || 1,
perPage: params?.pageSize || ITEMS_PER_PAGE,
search: params?.search || undefined,
equipmentId: (filters.equipmentId as string) || undefined,
repairType: (filters.repairType as string) || undefined,
dateFrom: dateFrom || undefined,
dateTo: dateTo || undefined,
});
if (result.success) {
return {
success: true,
data: result.data,
totalCount: result.pagination.total,
totalPages: result.pagination.lastPage,
};
}
return { success: false, error: result.error };
},
},
headerActions: () => (
<Button
size="sm"
onClick={() => router.push('/quality/equipment-repairs?mode=new')}
>
<Plus className="h-4 w-4 mr-1" />
</Button>
),
// 필터 (공용 MobileFilter 연동)
filterConfig,
filterTitle: '수리이력 필터',
// 날짜 범위 선택기
dateRangeSelector: {
enabled: true,
startDate: dateFrom,
endDate: dateTo,
onStartDateChange: setDateFrom,
onEndDateChange: setDateTo,
presets: ['thisMonth', 'lastMonth', 'twoMonthsAgo'] as import('@/components/molecules/DateRangeSelector').DatePreset[],
},
columns: [
{ key: 'repairDate', label: '수리일', className: 'w-[110px] text-center' },
{ key: 'equipment', label: '설비', className: 'min-w-[180px]' },
{ key: 'repairType', label: '보전구분', className: 'w-[80px] text-center' },
{ key: 'repairHours', label: '수리시간', className: 'w-[80px] text-center' },
{ key: 'description', label: '수리내용', className: 'min-w-[250px]' },
{ key: 'cost', label: '비용', className: 'w-[110px] text-right' },
{ key: 'vendor', label: '외주업체', className: 'w-[100px]' },
{ key: 'actions', label: '액션', className: 'w-[60px] text-center' },
],
clientSideFiltering: false,
itemsPerPage: ITEMS_PER_PAGE,
hideSearch: false,
searchPlaceholder: '설비명/수리내용 검색...',
renderTableRow: (
item: EquipmentRepair,
_index: number,
_globalIndex: number,
handlers: SelectionHandlers & RowClickHandlers<EquipmentRepair>
) => (
<TableRow key={item.id} className="hover:bg-muted/50">
<TableCell className="text-center">
<Checkbox
checked={handlers.isSelected}
onCheckedChange={() => handlers.onToggle()}
onClick={(e) => e.stopPropagation()}
/>
</TableCell>
<TableCell className="text-center">{item.repairDate}</TableCell>
<TableCell>
<span className="font-medium text-blue-600">{item.equipmentCode}</span>
{' '}{item.equipmentName}
</TableCell>
<TableCell className="text-center">
{item.repairType ? (
<Badge className={REPAIR_TYPE_COLOR[item.repairType]} variant="secondary">
{REPAIR_TYPE_LABEL[item.repairType]}
</Badge>
) : '-'}
</TableCell>
<TableCell className="text-center">{item.repairHours ? `${item.repairHours}h` : '-'}</TableCell>
<TableCell className="truncate max-w-[250px]">{item.description || '-'}</TableCell>
<TableCell className="text-right">{item.cost ? `${Number(item.cost).toLocaleString()}` : '-'}</TableCell>
<TableCell>{item.vendor || '-'}</TableCell>
<TableCell className="text-center">
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-red-600 hover:text-red-800"
onClick={(e) => handleDeleteClick(e, item)}
>
</Button>
</TableCell>
</TableRow>
),
renderMobileCard: (
item: EquipmentRepair,
_index: number,
_globalIndex: number,
handlers: SelectionHandlers & RowClickHandlers<EquipmentRepair>
) => (
<ListMobileCard
key={item.id}
title={`${item.equipmentCode} ${item.equipmentName}`}
headerBadges={
item.repairType ? (
<Badge className={REPAIR_TYPE_COLOR[item.repairType]} variant="secondary">
{REPAIR_TYPE_LABEL[item.repairType]}
</Badge>
) : undefined
}
infoGrid={
<div className="grid grid-cols-1 gap-1">
<InfoField label="수리일" value={item.repairDate} />
<InfoField label="비용" value={item.cost ? `${Number(item.cost).toLocaleString()}` : '-'} />
<InfoField label="수리시간" value={item.repairHours ? `${item.repairHours}h` : '-'} />
<InfoField label="외주업체" value={item.vendor || '-'} />
</div>
}
showCheckbox
isSelected={handlers.isSelected}
onToggleSelection={() => handlers.onToggle()}
actions={[
{
label: '삭제',
icon: Trash2,
variant: 'destructive' as const,
onClick: () => setDeleteTarget(item),
},
]}
/>
),
}),
[filterConfig, dateFrom, dateTo, handleDeleteClick, router]
);
return (
<>
<UniversalListPage
key={`${refreshKey}-${dateFrom}-${dateTo}`}
config={config}
/>
<DeleteConfirmDialog
open={!!deleteTarget}
onOpenChange={(open: boolean) => !open && setDeleteTarget(null)}
itemName={deleteTarget ? `${deleteTarget.equipmentCode} ${deleteTarget.repairDate}` : undefined}
description={`"${deleteTarget?.equipmentCode} ${deleteTarget?.repairDate}" 수리이력을 삭제하시겠습니까?`}
loading={isDeleting}
onConfirm={handleDeleteConfirm}
/>
</>
);
}

View File

@@ -0,0 +1,202 @@
'use client';
/**
* 설비 현황 대시보드
*
* 통계 카드 + 이번달 점검 현황 / 설비 유형별 현황 + 최근 수리이력
*/
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Loader2, ArrowRight } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
getEquipmentStats,
getRepairList,
} from '@/components/quality/EquipmentManagement/actions';
import type { EquipmentStats, EquipmentRepair } from '@/components/quality/EquipmentManagement/types';
import {
REPAIR_TYPE_LABEL,
REPAIR_TYPE_COLOR,
} from '@/components/quality/EquipmentManagement/types';
export function EquipmentStatusDashboard() {
const router = useRouter();
const [stats, setStats] = useState<EquipmentStats | null>(null);
const [recentRepairs, setRecentRepairs] = useState<EquipmentRepair[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
Promise.all([
getEquipmentStats(),
getRepairList({ perPage: 5 }),
]).then(([statsResult, repairsResult]) => {
if (statsResult.success && statsResult.data) {
setStats(statsResult.data);
}
if (repairsResult.success) {
setRecentRepairs(repairsResult.data);
}
setIsLoading(false);
});
}, []);
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
const now = new Date();
const currentYear = now.getFullYear();
const currentMonth = String(now.getMonth() + 1).padStart(2, '0');
return (
<div className="space-y-6 p-3 md:p-6">
<div>
<h1 className="text-xl font-bold"> </h1>
<p className="text-sm text-muted-foreground">{currentYear} {currentMonth} {String(now.getDate()).padStart(2, '0')} </p>
</div>
{/* 통계 카드 4개 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[
{ label: '총 설비', value: stats?.total ?? 0, color: '' },
{ label: '가동 중', value: stats?.active ?? 0, color: 'text-green-600' },
{ label: '유휴', value: stats?.idle ?? 0, color: 'text-yellow-600' },
{ label: '폐기', value: stats?.disposed ?? 0, color: 'text-red-600' },
].map((card) => (
<Card key={card.label}>
<CardContent className="pt-4 pb-4">
<p className="text-sm text-muted-foreground">{card.label}</p>
<p className={`text-3xl font-bold ${card.color}`}>{card.value}</p>
<p className="text-xs text-muted-foreground"></p>
</CardContent>
</Card>
))}
</div>
{/* 이번달 점검 현황 + 설비 유형별 현황 (2컬럼) */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* 이번달 점검 현황 */}
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
<p className="text-sm text-muted-foreground">{currentYear} {currentMonth}</p>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-sm"> </span>
<span className="text-sm font-bold">{stats?.inspectionStats?.targetCount ?? 0}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm"> </span>
<span className="text-sm font-bold text-green-600">{stats?.inspectionStats?.completedCount ?? 0}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm"> </span>
<span className="text-sm font-bold">{stats?.inspectionStats?.issueCount ?? 0}</span>
</div>
</div>
</CardContent>
</Card>
{/* 설비 유형별 현황 */}
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent>
{(!stats?.typeDistribution || stats.typeDistribution.length === 0) ? (
<div className="text-center py-6 text-muted-foreground text-sm">
.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead className="w-[100px] text-right"> </TableHead>
</TableRow>
</TableHeader>
<TableBody>
{stats.typeDistribution.map((item) => (
<TableRow key={item.equipmentType}>
<TableCell>{item.equipmentType}</TableCell>
<TableCell className="text-right">{item.count}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
{/* 최근 수리이력 */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-base"> </CardTitle>
<Button
variant="ghost"
size="sm"
className="text-blue-600"
onClick={() => router.push('/quality/equipment-repairs')}
>
<ArrowRight className="h-4 w-4 ml-1" />
</Button>
</div>
</CardHeader>
<CardContent>
{recentRepairs.length === 0 ? (
<div className="text-center py-6 text-muted-foreground text-sm">
.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-[80px] text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{recentRepairs.map((repair) => (
<TableRow key={repair.id}>
<TableCell>{repair.repairDate}</TableCell>
<TableCell>{repair.equipmentCode} {repair.equipmentName}</TableCell>
<TableCell className="truncate max-w-[200px]">{repair.description || '-'}</TableCell>
<TableCell className="text-center">
{repair.repairType ? (
<Badge className={REPAIR_TYPE_COLOR[repair.repairType]} variant="secondary">
{REPAIR_TYPE_LABEL[repair.repairType]}
</Badge>
) : '-'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}