+
{extraActions}
)}
diff --git a/src/components/pricing/PricingFormClient.tsx b/src/components/pricing/PricingFormClient.tsx
index 410b31f3..f84224ac 100644
--- a/src/components/pricing/PricingFormClient.tsx
+++ b/src/components/pricing/PricingFormClient.tsx
@@ -18,7 +18,7 @@ import { formatNumber } from '@/lib/utils/amount';
import {
DollarSign,
Package,
- ArrowLeft,
+ X,
Save,
Calculator,
TrendingUp,
@@ -27,6 +27,7 @@ import {
CheckCircle2,
Lock,
} from 'lucide-react';
+import { useMenuStore } from '@/stores/menuStore';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -90,6 +91,7 @@ export function PricingFormClient({
onFinalize,
}: PricingFormClientProps) {
const router = useRouter();
+ const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed);
const isEditMode = mode === 'edit';
// 품목 정보 (신규: itemInfo, 수정: initialData)
@@ -711,40 +713,38 @@ export function PricingFormClient({
- {/* 버튼 영역 */}
-
+ {/* 하단 버튼 (sticky 하단 바) */}
+
+
{isEditMode && initialData?.revisions && initialData.revisions.length > 0 && (
)}
-
{isEditMode && initialData && !initialData.isFinal && (
)}
diff --git a/src/components/quality/EquipmentInspection/index.tsx b/src/components/quality/EquipmentInspection/index.tsx
new file mode 100644
index 00000000..13cbd84c
--- /dev/null
+++ b/src/components/quality/EquipmentInspection/index.tsx
@@ -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
;
+ canInspect: boolean;
+ overallJudgment: string | null;
+}
+
+interface GridData {
+ rows: GridRow[];
+ labels: string[];
+ nonWorkingDays: string[];
+}
+
+export function EquipmentInspectionGrid() {
+ const searchParams = useSearchParams();
+ const [cycle, setCycle] = useState('daily');
+ const [period, setPeriod] = useState(() => {
+ const now = new Date();
+ return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
+ });
+ const [lineFilter, setLineFilter] = useState('all');
+ const [equipmentFilter, setEquipmentFilter] = useState(() => {
+ if (typeof window === 'undefined') return 'all';
+ const eqId = new URLSearchParams(window.location.search).get('equipment_id');
+ return eqId || 'all';
+ });
+ const [options, setOptions] = useState(null);
+ const [gridData, setGridData] = useState({ 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 변환
+ const detailMap: Record = {};
+ 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) => ({
+ 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 (
+
+
일상점검표
+
+ {/* 주기 탭 버튼 */}
+
+ {INSPECTION_CYCLES.map((c) => (
+
+ ))}
+
+
+ {/* 필터: 점검년월 / 생산라인 / 설비 / 조회 / 전체 초기화 */}
+
+
+
+
+
+ {
+ if (v) setPeriod(v.substring(0, 7));
+ }}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* 그리드 */}
+ {isLoading ? (
+
+
+
+ ) : gridData.rows.length === 0 ? (
+
+
+
+
+
점검 가능한 설비가 없습니다.
+
+ 설비 등록대장에서 해당 주기의 점검항목을 추가해주세요.
+
+
+
+
+
+ ) : (
+
+
+
+ {INSPECTION_CYCLE_LABEL[cycle]} 점검표 - {period}
+
+
+
+ {(() => {
+ 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 (
+
+
+
+
+ | 설비 |
+ 점검부위 |
+ 점검항목 |
+ {gridData.labels.map((label) => {
+ const isHoliday = gridData.nonWorkingDays.includes(label);
+ return (
+
+ {label.split('-').pop()}
+ |
+ );
+ })}
+ 판정 |
+
+
+
+ {gridData.rows.map((row) =>
+ row.templates.length === 0 ? (
+
+ |
+ {row.equipment.equipmentCode}
+ {row.equipment.name}
+ |
+
+ 점검항목 없음
+ |
+
+ ) : (
+ row.templates.map((template, tIdx) => (
+
+ {tIdx === 0 && (
+ |
+ {row.equipment.equipmentCode}
+ {row.equipment.name}
+ |
+ )}
+ {template.checkPoint} |
+ {template.checkItem} |
+ {gridData.labels.map((label) => {
+ const key = `${template.id}_${label}`;
+ const result = row.details[key];
+ const isNonWorking = gridData.nonWorkingDays.includes(label);
+ return (
+
+ row.canInspect &&
+ handleCellClick(row.equipment.id, template.id, label)
+ }
+ >
+ {getResultSymbol(result)}
+ |
+ );
+ })}
+ {tIdx === 0 && (
+
+ {row.overallJudgment || '-'}
+ |
+ )}
+
+ ))
+ )
+ )}
+
+
+
+ );
+ })()}
+
+ {/* 범례 */}
+
+
+ ○ 양호
+
+
+ X 불량
+
+
+ △ 수리
+
+
+ 휴일
+
+
+
+
+ )}
+
+ {/* 전체 초기화 확인 */}
+
+
+ );
+}
diff --git a/src/components/quality/EquipmentManagement/EquipmentDetail.tsx b/src/components/quality/EquipmentManagement/EquipmentDetail.tsx
new file mode 100644
index 00000000..a248a968
--- /dev/null
+++ b/src/components/quality/EquipmentManagement/EquipmentDetail.tsx
@@ -0,0 +1,1164 @@
+'use client';
+
+/**
+ * 설비 상세 페이지
+ *
+ * 3탭 구성: 기본정보, 점검항목, 수리이력
+ * URL: /quality/equipment/[id]
+ * 수정: /quality/equipment/[id]?mode=edit
+ */
+
+import { useState, useEffect, useCallback, useRef } from 'react';
+import { useRouter, useSearchParams } from 'next/navigation';
+import {
+ ArrowLeft,
+ Pencil,
+ Save,
+ X,
+ Trash2,
+ Loader2,
+ Wrench,
+ ClipboardList,
+ History,
+ Plus,
+ Copy,
+ Download,
+ Printer,
+ ImagePlus,
+} from 'lucide-react';
+import { toast } from 'sonner';
+import { QRCodeCanvas } from 'qrcode.react';
+import { useMenuStore } from '@/stores/menuStore';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Badge } from '@/components/ui/badge';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table';
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogFooter,
+} from '@/components/ui/dialog';
+import { Checkbox } from '@/components/ui/checkbox';
+import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
+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 {
+ getEquipmentDetail,
+ updateEquipment,
+ deleteEquipment,
+ getEquipmentOptions,
+ getInspectionTemplates,
+ createInspectionTemplate,
+ deleteInspectionTemplate,
+ copyInspectionTemplates,
+ getRepairList,
+ getManagerOptions,
+ uploadEquipmentPhoto,
+ deleteEquipmentPhoto,
+} from './actions';
+import type {
+ Equipment,
+ EquipmentFormData,
+ EquipmentOptions,
+ InspectionTemplate,
+ InspectionTemplateFormData,
+ InspectionCycle,
+ EquipmentRepair,
+ ManagerOption,
+} from './types';
+import {
+ EQUIPMENT_STATUS_LABEL,
+ EQUIPMENT_STATUS_COLOR,
+ INSPECTION_CYCLE_LABEL,
+ INSPECTION_CYCLES,
+ REPAIR_TYPE_LABEL,
+} from './types';
+
+// ===== QR 코드 URL 생성 =====
+const QR_BASE_URL = typeof window !== 'undefined'
+ ? `${window.location.protocol}//${window.location.host}`
+ : 'https://admin.codebridge-x.com';
+
+interface EquipmentDetailProps {
+ id: string;
+}
+
+export function EquipmentDetail({ id }: EquipmentDetailProps) {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed);
+ const initialMode = searchParams.get('mode') === 'edit' ? 'edit' : 'view';
+
+ const [equipment, setEquipment] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [mode, setMode] = useState<'view' | 'edit'>(initialMode);
+ const [activeTab, setActiveTab] = useState('info');
+
+ // 수정 폼 상태
+ const [formData, setFormData] = useState(null);
+ const [options, setOptions] = useState(null);
+ const [managers, setManagers] = useState([]);
+ const [isSaving, setIsSaving] = useState(false);
+
+ // 삭제
+ const [showDelete, setShowDelete] = useState(false);
+ const [isDeleting, setIsDeleting] = useState(false);
+
+ // 점검항목 탭
+ const [selectedCycle, setSelectedCycle] = useState('daily');
+ const [templates, setTemplates] = useState([]);
+ const [isLoadingTemplates, setIsLoadingTemplates] = useState(false);
+ const [showAddTemplate, setShowAddTemplate] = useState(false);
+ const [showCopyDialog, setShowCopyDialog] = useState(false);
+ const [copyTargetCycles, setCopyTargetCycles] = useState([]);
+ const [newTemplate, setNewTemplate] = useState({
+ inspectionCycle: 'daily',
+ itemNo: '',
+ checkPoint: '',
+ checkItem: '',
+ checkTiming: '',
+ checkFrequency: '',
+ checkMethod: '',
+ });
+
+ // 수리이력 탭
+ const [repairs, setRepairs] = useState([]);
+ const [isLoadingRepairs, setIsLoadingRepairs] = useState(false);
+
+ // 사진 업로드
+ const [isUploading, setIsUploading] = useState(false);
+ const photoInputRef = useRef(null);
+
+ // QR 코드 ref
+ const qrRef = useRef(null);
+
+ // 데이터 로드
+ const loadEquipment = useCallback(async () => {
+ setIsLoading(true);
+ const result = await getEquipmentDetail(id);
+ if (result.success && result.data) {
+ setEquipment(result.data);
+ } else {
+ toast.error(result.error || '설비 데이터를 불러올 수 없습니다.');
+ router.push('/quality/equipment');
+ }
+ setIsLoading(false);
+ }, [id, router]);
+
+ useEffect(() => {
+ loadEquipment();
+ getEquipmentOptions().then((r) => {
+ if (r.success && r.data) setOptions(r.data);
+ });
+ getManagerOptions().then((mgrs) => setManagers(mgrs));
+ }, [loadEquipment]);
+
+ // 점검항목 로드
+ const loadTemplates = useCallback(async () => {
+ setIsLoadingTemplates(true);
+ const result = await getInspectionTemplates(id, selectedCycle);
+ if (result.success && result.data) {
+ setTemplates(result.data);
+ }
+ setIsLoadingTemplates(false);
+ }, [id, selectedCycle]);
+
+ useEffect(() => {
+ if (activeTab === 'inspection') {
+ loadTemplates();
+ }
+ }, [activeTab, loadTemplates]);
+
+ // 수리이력 로드
+ const loadRepairs = useCallback(async () => {
+ setIsLoadingRepairs(true);
+ const result = await getRepairList({ equipmentId: id, perPage: 100 });
+ if (result.success) {
+ setRepairs(result.data);
+ }
+ setIsLoadingRepairs(false);
+ }, [id]);
+
+ useEffect(() => {
+ if (activeTab === 'repairs') {
+ loadRepairs();
+ }
+ }, [activeTab, loadRepairs]);
+
+ // 수정 모드 진입
+ const enterEditMode = useCallback(() => {
+ if (!equipment) return;
+ setFormData({
+ equipmentCode: equipment.equipmentCode,
+ name: equipment.name,
+ equipmentType: equipment.equipmentType,
+ specification: equipment.specification,
+ manufacturer: equipment.manufacturer,
+ modelName: equipment.modelName,
+ serialNo: equipment.serialNo,
+ location: equipment.location,
+ productionLine: equipment.productionLine,
+ purchaseDate: equipment.purchaseDate,
+ installDate: equipment.installDate,
+ purchasePrice: equipment.purchasePrice,
+ usefulLife: equipment.usefulLife ? String(equipment.usefulLife) : '',
+ status: equipment.status,
+ managerId: equipment.managerId ? String(equipment.managerId) : '',
+ subManagerId: equipment.subManagerId ? String(equipment.subManagerId) : '',
+ memo: equipment.memo,
+ });
+ setMode('edit');
+ }, [equipment]);
+
+ // URL ?mode=edit로 직접 진입 시 equipment 로드 후 formData 자동 설정
+ useEffect(() => {
+ if (equipment && mode === 'edit' && !formData) {
+ enterEditMode();
+ }
+ }, [equipment, mode, formData, enterEditMode]);
+
+ const cancelEdit = useCallback(() => {
+ setMode('view');
+ setFormData(null);
+ }, []);
+
+ const handleFormChange = useCallback((field: keyof EquipmentFormData, value: string) => {
+ setFormData((prev) => prev ? { ...prev, [field]: value } : prev);
+ }, []);
+
+ const handleSave = useCallback(async () => {
+ if (!formData) return;
+ if (!formData.equipmentCode.trim() || !formData.name.trim()) {
+ toast.error('설비코드와 설비명은 필수입니다.');
+ return;
+ }
+ setIsSaving(true);
+ try {
+ const result = await updateEquipment(id, formData);
+ if (result.success) {
+ toast.success('설비가 수정되었습니다.');
+ setMode('view');
+ setFormData(null);
+ await loadEquipment();
+ } else {
+ toast.error(result.error || '수정에 실패했습니다.');
+ }
+ } finally {
+ setIsSaving(false);
+ }
+ }, [id, formData, loadEquipment]);
+
+ const handleDelete = useCallback(async () => {
+ setIsDeleting(true);
+ try {
+ const result = await deleteEquipment(id);
+ if (result.success) {
+ toast.success('설비가 삭제되었습니다.');
+ router.push('/quality/equipment');
+ } else {
+ toast.error(result.error || '삭제에 실패했습니다.');
+ }
+ } finally {
+ setIsDeleting(false);
+ setShowDelete(false);
+ }
+ }, [id, router]);
+
+ // 점검항목 추가
+ const handleAddTemplate = useCallback(async () => {
+ if (!newTemplate.checkPoint.trim() || !newTemplate.checkItem.trim()) {
+ toast.error('점검개소와 점검항목을 입력하세요.');
+ return;
+ }
+ const result = await createInspectionTemplate(id, {
+ ...newTemplate,
+ inspectionCycle: selectedCycle,
+ });
+ if (result.success) {
+ toast.success('점검항목이 추가되었습니다.');
+ setShowAddTemplate(false);
+ setNewTemplate({
+ inspectionCycle: selectedCycle,
+ itemNo: '',
+ checkPoint: '',
+ checkItem: '',
+ checkTiming: '',
+ checkFrequency: '',
+ checkMethod: '',
+ });
+ await loadTemplates();
+ } else {
+ toast.error(result.error || '추가에 실패했습니다.');
+ }
+ }, [id, selectedCycle, newTemplate, loadTemplates]);
+
+ const handleDeleteTemplate = useCallback(async (templateId: number) => {
+ const result = await deleteInspectionTemplate(templateId);
+ if (result.success) {
+ toast.success('점검항목이 삭제되었습니다.');
+ await loadTemplates();
+ } else {
+ toast.error(result.error || '삭제에 실패했습니다.');
+ }
+ }, [loadTemplates]);
+
+ // 점검항목 복사
+ const handleCopyTemplates = useCallback(async () => {
+ if (copyTargetCycles.length === 0) {
+ toast.error('복사할 주기를 선택하세요.');
+ return;
+ }
+ const result = await copyInspectionTemplates(id, selectedCycle, copyTargetCycles);
+ if (result.success) {
+ toast.success('점검항목이 복사되었습니다.');
+ setShowCopyDialog(false);
+ setCopyTargetCycles([]);
+ } else {
+ toast.error(result.error || '복사에 실패했습니다.');
+ }
+ }, [id, selectedCycle, copyTargetCycles]);
+
+ // QR 코드 다운로드
+ const handleQRDownload = useCallback(() => {
+ const canvas = qrRef.current?.querySelector('canvas');
+ if (!canvas) return;
+ const url = canvas.toDataURL('image/png');
+ const link = document.createElement('a');
+ link.download = `${equipment?.equipmentCode || 'qr'}.png`;
+ link.href = url;
+ link.click();
+ }, [equipment]);
+
+ // QR 코드 인쇄
+ const handleQRPrint = useCallback(() => {
+ const canvas = qrRef.current?.querySelector('canvas');
+ if (!canvas) return;
+ const url = canvas.toDataURL('image/png');
+ const win = window.open('', '_blank');
+ if (!win) return;
+ win.document.write(`
+ QR 코드 인쇄
+
+
+

+
${equipment?.equipmentCode}
+
${equipment?.name}
+
+
+ `);
+ win.document.close();
+ }, [equipment]);
+
+ // 사진 업로드
+ const handlePhotoUpload = useCallback(async (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+ setIsUploading(true);
+ try {
+ const result = await uploadEquipmentPhoto(id, file);
+ if (result.success) {
+ toast.success('사진이 업로드되었습니다.');
+ await loadEquipment();
+ } else {
+ toast.error(result.error || '사진 업로드에 실패했습니다.');
+ }
+ } finally {
+ setIsUploading(false);
+ if (photoInputRef.current) photoInputRef.current.value = '';
+ }
+ }, [id, loadEquipment]);
+
+ // 사진 삭제
+ const handlePhotoDelete = useCallback(async (fileId: number) => {
+ const result = await deleteEquipmentPhoto(id, fileId);
+ if (result.success) {
+ toast.success('사진이 삭제되었습니다.');
+ await loadEquipment();
+ } else {
+ toast.error(result.error || '사진 삭제에 실패했습니다.');
+ }
+ }, [id, loadEquipment]);
+
+ if (isLoading || !equipment) {
+ return (
+
+
+
+ );
+ }
+
+ const qrUrl = `${QR_BASE_URL}/quality/equipment-inspections?equipment_id=${equipment.id}`;
+
+ const InfoRow = ({ label, value }: { label: string; value: string | number | null }) => (
+
+ {label}
+ {value || '-'}
+
+ );
+
+ return (
+
+ {/* 헤더 */}
+
+
+
+
+
+
{equipment.name}
+
+ {EQUIPMENT_STATUS_LABEL[equipment.status]}
+
+
+
{equipment.equipmentCode}
+
+
+
+
+ {/* 탭 */}
+
+
+ 기본정보
+ 점검항목
+ 수리이력
+
+
+ {/* ==================== 기본정보 탭 ==================== */}
+
+ {mode === 'view' ? (
+ <>
+ {/* 기본정보 */}
+
+ 기본정보
+
+
+
+
+
+
+
+
+
+
+ {/* 제조사 정보 */}
+
+ 제조사 정보
+
+
+
+
+
+
+
+
+
+ {/* 설치 정보 */}
+
+ 설치 정보
+
+
+
+
+
+
+
+
+
+
+
+
+
상태
+
+
+ {EQUIPMENT_STATUS_LABEL[equipment.status]}
+
+
+
+ {equipment.memo && (
+
+
비고
+
{equipment.memo}
+
+ )}
+
+
+
+ {/* 설비 사진 */}
+
+
+
+
설비 사진
+
+
+
+
+
+
+
+ {equipment.photos.length === 0 ? (
+
+ 등록된 사진이 없습니다.
+
+ ) : (
+
+ {equipment.photos.map((photo) => (
+
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+

+
+
+
{photo.displayName}
+
+ ))}
+
+ )}
+ {equipment.photos.length >= 10 && (
+ 최대 10장까지 등록 가능합니다.
+ )}
+
+
+
+ {/* QR 코드 */}
+
+ QR 코드 (모바일 점검)
+
+
+
+
+
+
+
{equipment.equipmentCode}
+
{equipment.name}
+
{qrUrl}
+
+
+
+
+
+
+
+
+ >
+ ) : formData && (
+ /* 수정 모드 */
+ <>
+
+ 기본정보
+
+
+
handleFormChange('equipmentCode', v)} placeholder="KD-M-001" />
+ handleFormChange('name', v)} placeholder="포밍기#1" />
+
+
+
+
+
+
+ handleFormChange('specification', v)} />
+
+
+
+
+
+ 제조사 정보
+
+
+ handleFormChange('manufacturer', v)} />
+ handleFormChange('modelName', v)} />
+ handleFormChange('serialNo', v)} />
+
+
+
+
+
+ 설치 정보
+
+
+
handleFormChange('location', v)} placeholder="1공장-1F" />
+
+
+
+
+
+
+ handleFormChange('purchaseDate', v)} />
+ handleFormChange('installDate', v)} />
+ handleFormChange('purchasePrice', v)} />
+ handleFormChange('usefulLife', v)} />
+
+
+
+
+
+ 관리자 / 비고
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* 설비 사진 (수정 모드) */}
+
+
+
+
설비 사진
+
+
+
+
+
+
+
+ {equipment.photos.length === 0 ? (
+
+ 등록된 사진이 없습니다.
+
+ ) : (
+
+ {equipment.photos.map((photo) => (
+
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+

+
+
+
{photo.displayName}
+
+ ))}
+
+ )}
+ {equipment.photos.length >= 10 && (
+ 최대 10장까지 등록 가능합니다.
+ )}
+
+
+ >
+ )}
+
+
+ {/* ==================== 점검항목 탭 ==================== */}
+
+
+
+ 점검항목 템플릿
+
+
+
+
+
+
+ {/* 주기 탭 버튼 */}
+
+ {INSPECTION_CYCLES.map((cycle) => (
+
+ ))}
+
+
+ {/* 점검항목 목록 */}
+ {isLoadingTemplates ? (
+
+
+
+ ) : templates.length === 0 ? (
+
+ 등록된 점검항목이 없습니다.
+
+ ) : (
+ <>
+ {/* PC: 테이블 */}
+
+
+
+
+ 번호
+ 점검개소
+ 점검항목
+ 시기
+ 주기
+ 점검방법
+ 액션
+
+
+
+ {templates.map((t, idx) => (
+
+ {t.itemNo || idx + 1}
+ {t.checkPoint}
+ {t.checkItem}
+ {t.checkTiming || '-'}
+ {t.checkFrequency || '-'}
+ {t.checkMethod || '-'}
+
+
+
+
+ ))}
+
+
+
+ {/* 모바일: 카드 리스트 */}
+
+ {templates.map((t, idx) => (
+
+
+ {t.itemNo || idx + 1}. {t.checkPoint}
+
+
+
+
점검항목: {t.checkItem}
+
시기: {t.checkTiming || '-'}
+
주기: {t.checkFrequency || '-'}
+
+ {t.checkMethod && (
+
{t.checkMethod}
+ )}
+
+ ))}
+
+ >
+ )}
+
+
+
+
+ {/* ==================== 수리이력 탭 ==================== */}
+
+
+ 수리이력
+
+ {isLoadingRepairs ? (
+
+
+
+ ) : repairs.length === 0 ? (
+
+ 수리이력이 없습니다.
+
+ ) : (
+ <>
+ {/* PC: 테이블 */}
+
+
+
+
+ 번호
+ 수리일
+ 보전구분
+ 수리내용
+ 수리시간
+ 수리비용
+ 외주업체
+ 수리자
+
+
+
+ {repairs.map((r, idx) => (
+
+ {idx + 1}
+ {r.repairDate}
+
+ {r.repairType ? (
+
+ {REPAIR_TYPE_LABEL[r.repairType]}
+
+ ) : '-'}
+
+ {r.description || '-'}
+ {r.repairHours ? `${r.repairHours}h` : '-'}
+ {r.cost ? Number(r.cost).toLocaleString() + '원' : '-'}
+ {r.vendor || '-'}
+ {r.repairerName || '-'}
+
+ ))}
+
+
+
+ {/* 모바일: 카드 리스트 */}
+
+ {repairs.map((r, idx) => (
+
+
+ {r.repairDate}
+ {r.repairType ? (
+
+ {REPAIR_TYPE_LABEL[r.repairType]}
+
+ ) : null}
+
+
{r.description || '-'}
+
+
수리시간: {r.repairHours ? `${r.repairHours}h` : '-'}
+
수리비용: {r.cost ? Number(r.cost).toLocaleString() + '원' : '-'}
+
외주업체: {r.vendor || '-'}
+
수리자: {r.repairerName || '-'}
+
+
+ ))}
+
+ >
+ )}
+
+
+
+
+
+ {/* ==================== 하단 버튼 (sticky 하단 바) ==================== */}
+
+ {mode === 'view' ? (
+ <>
+
+
+
+
+
+ >
+ ) : (
+ <>
+
+
+ >
+ )}
+
+
+ {/* ==================== 모달들 ==================== */}
+
+ {/* 삭제 확인 */}
+
!open && setShowDelete(false)}
+ itemName={equipment.name}
+ description={`"${equipment.name}" 설비를 삭제하시겠습니까?`}
+ loading={isDeleting}
+ onConfirm={handleDelete}
+ />
+
+ {/* 점검항목 추가 모달 */}
+
+
+ {/* 점검항목 복사 모달 */}
+
+
+ );
+}
diff --git a/src/components/quality/EquipmentManagement/EquipmentForm.tsx b/src/components/quality/EquipmentManagement/EquipmentForm.tsx
new file mode 100644
index 00000000..96cecb1b
--- /dev/null
+++ b/src/components/quality/EquipmentManagement/EquipmentForm.tsx
@@ -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(initialFormData);
+ const [options, setOptions] = useState(null);
+ const [managers, setManagers] = useState([]);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [isLoading, setIsLoading] = useState(!!equipmentId && !initialData);
+ const [pendingPhotos, setPendingPhotos] = useState([]);
+ const [photoPreviews, setPhotoPreviews] = useState([]);
+ const photoInputRef = useRef(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) => {
+ 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 (
+
+
+
+ );
+ }
+
+ return (
+
+ {/* 헤더 */}
+
+
{isEditMode ? '설비 수정' : '설비 등록'}
+
+
+
+ {/* 기본정보 */}
+
+
+ 기본정보
+
+
+
+
+
handleChange('equipmentCode', v)}
+ placeholder="KD-M-001"
+ />
+ 예: KD-M-001, KD-S-002
+
+
handleChange('name', v)}
+ placeholder="포밍기#1"
+ />
+
+
+
+
+
+
+ handleChange('specification', v)}
+ placeholder="규격"
+ />
+
+
+
+
+ {/* 제조사 정보 */}
+
+
+ 제조사 정보
+
+
+
+ handleChange('manufacturer', v)}
+ placeholder="제조사"
+ />
+ handleChange('modelName', v)}
+ placeholder="모델명"
+ />
+ handleChange('serialNo', v)}
+ placeholder="제조번호"
+ />
+
+
+
+
+ {/* 설치 정보 */}
+
+
+ 설치 정보
+
+
+
+
handleChange('location', v)}
+ placeholder="1공장-1F"
+ />
+
+
+
+
+
+
+ handleChange('purchaseDate', v)}
+ />
+ handleChange('installDate', v)}
+ />
+ handleChange('purchasePrice', v)}
+ placeholder="구입가격"
+ />
+ handleChange('usefulLife', v)}
+ placeholder="내용연수"
+ />
+
+
+
+
+ {/* 관리자 / 비고 */}
+
+
+ 관리자 / 비고
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* 설비 사진 */}
+ {!isEditMode && (
+
+
+
+
설비 사진
+
+
+
+
+
+
+
+ {pendingPhotos.length === 0 ? (
+
+ 등록할 사진을 추가하세요. (저장 시 함께 업로드됩니다)
+
+ ) : (
+
+ {pendingPhotos.map((file, index) => (
+
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+

+
+
+
{file.name}
+
+ ))}
+
+ )}
+ {pendingPhotos.length >= 10 && (
+ 최대 10장까지 등록 가능합니다.
+ )}
+
+
+ )}
+
+ {/* 하단 버튼 (sticky 하단 바) */}
+
+
+
+
+
+ );
+}
diff --git a/src/components/quality/EquipmentManagement/EquipmentImport.tsx b/src/components/quality/EquipmentManagement/EquipmentImport.tsx
new file mode 100644
index 00000000..4531134e
--- /dev/null
+++ b/src/components/quality/EquipmentManagement/EquipmentImport.tsx
@@ -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(null);
+ const [previewData, setPreviewData] = useState([]);
+ const [isUploading, setIsUploading] = useState(false);
+ const [isImporting, setIsImporting] = useState(false);
+
+ const handleFileChange = useCallback((e: React.ChangeEvent) => {
+ 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 (
+
+ {/* 헤더 */}
+
+
설비 엑셀 Import
+
+
+
+ {/* 파일 업로드 */}
+
+
+ 파일 업로드
+
+
+
+
+
+
+ 지원 형식: .xlsx, .xls, .csv
+
+
+
+
+
+ {/* 미리보기 */}
+ {previewData.length > 0 && (
+
+
+
+
+ 미리보기 ({previewData.length}건)
+
+
+
+
+
+
+
+
+
+ No.
+ 설비번호
+ 설비명
+ 유형
+ 규격
+ 제조사
+ 위치
+ 생산라인
+ 상태
+
+
+
+ {previewData.map((row, idx) => (
+
+ {idx + 1}
+ {row.equipmentCode || '-'}
+ {row.name || '-'}
+ {row.equipmentType || '-'}
+ {row.specification || '-'}
+ {row.manufacturer || '-'}
+ {row.location || '-'}
+ {row.productionLine || '-'}
+ {row.status || '-'}
+
+ ))}
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/src/components/quality/EquipmentManagement/actions.ts b/src/components/quality/EquipmentManagement/actions.ts
new file mode 100644
index 00000000..1ab0dcde
--- /dev/null
+++ b/src/components/quality/EquipmentManagement/actions.ts
@@ -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 {
+ 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 {
+ 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({
+ 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> {
+ const result = await executeServerAction({
+ 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> {
+ const result = await executeServerAction({
+ 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> {
+ const result = await executeServerAction({
+ 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 {
+ return executeServerAction({
+ url: buildApiUrl(`/api/v1/equipment/${id}`),
+ method: 'DELETE',
+ errorMessage: '설비 삭제에 실패했습니다.',
+ });
+}
+
+// ===== 옵션 / 통계 =====
+
+export async function getEquipmentOptions(): Promise> {
+ const result = await executeServerAction({
+ 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> {
+ const result = await executeServerAction({
+ 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> {
+ const result = await executeServerAction({
+ 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> {
+ const result = await executeServerAction({
+ 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 {
+ return executeServerAction({
+ url: buildApiUrl(`/api/v1/equipment/templates/${templateId}`),
+ method: 'DELETE',
+ errorMessage: '점검항목 삭제에 실패했습니다.',
+ });
+}
+
+export async function copyInspectionTemplates(
+ equipmentId: string,
+ sourceCycle: InspectionCycle,
+ targetCycles: InspectionCycle[]
+): Promise> {
+ 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> {
+ 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> {
+ 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> {
+ 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({
+ 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> {
+ const result = await executeServerAction({
+ 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 {
+ return executeServerAction({
+ url: buildApiUrl(`/api/v1/equipment/repairs/${id}`),
+ method: 'DELETE',
+ errorMessage: '수리이력 삭제에 실패했습니다.',
+ });
+}
+
+// ===== 설비 사진 =====
+
+export async function uploadEquipmentPhoto(equipmentId: string, file: File): Promise> {
+ const formData = new FormData();
+ formData.append('file', file);
+
+ return executeServerAction({
+ url: buildApiUrl(`/api/v1/equipment/${equipmentId}/photos`),
+ method: 'POST',
+ body: formData,
+ errorMessage: '사진 업로드에 실패했습니다.',
+ });
+}
+
+export async function deleteEquipmentPhoto(equipmentId: string, fileId: number): Promise {
+ 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 {
+ const result = await executeServerAction({
+ 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');
+}
diff --git a/src/components/quality/EquipmentManagement/index.tsx b/src/components/quality/EquipmentManagement/index.tsx
new file mode 100644
index 00000000..f318c062
--- /dev/null
+++ b/src/components/quality/EquipmentManagement/index.tsx
@@ -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(null);
+
+ // ===== 삭제 확인 =====
+ const [deleteTarget, setDeleteTarget] = useState(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 = 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: () => (
+
+
+
+
+ ),
+
+ // 필터 (공용 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
+ ) => (
+ handlers.onRowClick?.()}
+ >
+
+ handlers.onToggle()}
+ onClick={(e) => e.stopPropagation()}
+ />
+
+ {globalIndex}
+ {item.equipmentCode}
+ {item.name}
+ {item.equipmentType || '-'}
+ {item.location || '-'}
+ {item.productionLine || '-'}
+
+
+ {EQUIPMENT_STATUS_LABEL[item.status] || item.status}
+
+
+ {item.managerName || '-'}
+ {item.subManagerName || '-'}
+ {item.purchaseDate || '-'}
+ -
+
+
+
+
+
+
+
+ ),
+
+ // 모바일 카드 렌더링
+ renderMobileCard: (
+ item: Equipment,
+ index: number,
+ globalIndex: number,
+ handlers: SelectionHandlers & RowClickHandlers
+ ) => (
+
+ {EQUIPMENT_STATUS_LABEL[item.status]}
+
+ }
+ infoGrid={
+
+
+
+
+
+
+ }
+ 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 (
+ <>
+
+
+ !open && setDeleteTarget(null)}
+ itemName={deleteTarget?.name}
+ description={`"${deleteTarget?.name}" 설비를 삭제하시겠습니까?`}
+ loading={isDeleting}
+ onConfirm={handleDeleteConfirm}
+ />
+ >
+ );
+}
diff --git a/src/components/quality/EquipmentManagement/types.ts b/src/components/quality/EquipmentManagement/types.ts
new file mode 100644
index 00000000..6a993e0a
--- /dev/null
+++ b/src/components/quality/EquipmentManagement/types.ts
@@ -0,0 +1,352 @@
+// 설비 등록대장 타입 정의
+
+// ===== 설비 상태 =====
+export type EquipmentStatus = 'active' | 'idle' | 'disposed';
+
+export const EQUIPMENT_STATUS_LABEL: Record = {
+ active: '가동',
+ idle: '유휴',
+ disposed: '폐기',
+};
+
+export const EQUIPMENT_STATUS_COLOR: Record = {
+ 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 = {
+ 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 = {
+ good: '○',
+ bad: 'X',
+ repaired: '△',
+};
+
+// ===== 보전 구분 =====
+export type RepairType = 'internal' | 'external';
+
+export const REPAIR_TYPE_LABEL: Record = {
+ internal: '사내',
+ external: '외주',
+};
+
+export const REPAIR_TYPE_COLOR: Record = {
+ 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;
+ 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;
+ 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; // 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;
+}
diff --git a/src/components/quality/EquipmentRepair/RepairForm.tsx b/src/components/quality/EquipmentRepair/RepairForm.tsx
new file mode 100644
index 00000000..b670fa8b
--- /dev/null
+++ b/src/components/quality/EquipmentRepair/RepairForm.tsx
@@ -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(initialFormData);
+ const [options, setOptions] = useState(null);
+ const [managers, setManagers] = useState([]);
+ 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 (
+
+ {/* 헤더 */}
+
+
+ 수리이력 등록
+
+
+
+
+
+ {/* 폼 */}
+
+
+ {/* Row 1: 설비 | 수리일 */}
+
+
+
+
+
+
+
+
handleChange('repairDate', v)}
+ />
+
+
+ {/* Row 2: 보전구분 | 수리시간 */}
+
+
+
+
+
+
+
+
handleChange('repairHours', v)}
+ />
+
+
+ {/* Row 3: 수리내용 */}
+
+
+ {/* Row 4: 수리비용 | 외주업체 | 수리자 */}
+
+
handleChange('cost', v)}
+ />
+ handleChange('vendor', v)}
+ />
+
+
+
+
+
+
+
+
+ {/* Row 5: 비고 */}
+
+
+
+
+
+ {/* 하단 버튼 (sticky 하단 바) */}
+
+
+
+
+
+ );
+}
diff --git a/src/components/quality/EquipmentRepair/index.tsx b/src/components/quality/EquipmentRepair/index.tsx
new file mode 100644
index 00000000..ca7acec3
--- /dev/null
+++ b/src/components/quality/EquipmentRepair/index.tsx
@@ -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('');
+ const [dateTo, setDateTo] = useState('');
+ const [options, setOptions] = useState(null);
+
+ const [deleteTarget, setDeleteTarget] = useState(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 = 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: () => (
+
+ ),
+
+ // 필터 (공용 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
+ ) => (
+
+
+ handlers.onToggle()}
+ onClick={(e) => e.stopPropagation()}
+ />
+
+ {item.repairDate}
+
+ {item.equipmentCode}
+ {' '}{item.equipmentName}
+
+
+ {item.repairType ? (
+
+ {REPAIR_TYPE_LABEL[item.repairType]}
+
+ ) : '-'}
+
+ {item.repairHours ? `${item.repairHours}h` : '-'}
+ {item.description || '-'}
+ {item.cost ? `${Number(item.cost).toLocaleString()}원` : '-'}
+ {item.vendor || '-'}
+
+
+
+
+ ),
+
+ renderMobileCard: (
+ item: EquipmentRepair,
+ _index: number,
+ _globalIndex: number,
+ handlers: SelectionHandlers & RowClickHandlers
+ ) => (
+
+ {REPAIR_TYPE_LABEL[item.repairType]}
+
+ ) : undefined
+ }
+ infoGrid={
+
+
+
+
+
+
+ }
+ showCheckbox
+ isSelected={handlers.isSelected}
+ onToggleSelection={() => handlers.onToggle()}
+ actions={[
+ {
+ label: '삭제',
+ icon: Trash2,
+ variant: 'destructive' as const,
+ onClick: () => setDeleteTarget(item),
+ },
+ ]}
+ />
+ ),
+ }),
+ [filterConfig, dateFrom, dateTo, handleDeleteClick, router]
+ );
+
+ return (
+ <>
+
+
+ !open && setDeleteTarget(null)}
+ itemName={deleteTarget ? `${deleteTarget.equipmentCode} ${deleteTarget.repairDate}` : undefined}
+ description={`"${deleteTarget?.equipmentCode} ${deleteTarget?.repairDate}" 수리이력을 삭제하시겠습니까?`}
+ loading={isDeleting}
+ onConfirm={handleDeleteConfirm}
+ />
+ >
+ );
+}
diff --git a/src/components/quality/EquipmentStatus/index.tsx b/src/components/quality/EquipmentStatus/index.tsx
new file mode 100644
index 00000000..1b2bfd15
--- /dev/null
+++ b/src/components/quality/EquipmentStatus/index.tsx
@@ -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(null);
+ const [recentRepairs, setRecentRepairs] = useState([]);
+ 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 (
+
+
+
+ );
+ }
+
+ const now = new Date();
+ const currentYear = now.getFullYear();
+ const currentMonth = String(now.getMonth() + 1).padStart(2, '0');
+
+ return (
+
+
+
설비 현황
+
{currentYear}년 {currentMonth}월 {String(now.getDate()).padStart(2, '0')}일 기준
+
+
+ {/* 통계 카드 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.label}
+ {card.value}
+ 대
+
+
+ ))}
+
+
+ {/* 이번달 점검 현황 + 설비 유형별 현황 (2컬럼) */}
+
+ {/* 이번달 점검 현황 */}
+
+
+ 이번달 점검 현황
+ {currentYear}년 {currentMonth}월
+
+
+
+
+ 점검 대상
+ {stats?.inspectionStats?.targetCount ?? 0}대
+
+
+ 점검 완료
+ {stats?.inspectionStats?.completedCount ?? 0}대
+
+
+ 이상 발견
+ {stats?.inspectionStats?.issueCount ?? 0}건
+
+
+
+
+
+ {/* 설비 유형별 현황 */}
+
+
+ 설비 유형별 현황
+
+
+ {(!stats?.typeDistribution || stats.typeDistribution.length === 0) ? (
+
+ 데이터가 없습니다.
+
+ ) : (
+
+
+
+ 유형
+ 설비 수
+
+
+
+ {stats.typeDistribution.map((item) => (
+
+ {item.equipmentType}
+ {item.count}대
+
+ ))}
+
+
+ )}
+
+
+
+
+ {/* 최근 수리이력 */}
+
+
+
+
최근 수리이력
+
+
+
+
+ {recentRepairs.length === 0 ? (
+
+ 최근 수리이력이 없습니다.
+
+ ) : (
+
+
+
+ 수리일
+ 설비
+ 내용
+ 구분
+
+
+
+ {recentRepairs.map((repair) => (
+
+ {repair.repairDate}
+ {repair.equipmentCode} {repair.equipmentName}
+ {repair.description || '-'}
+
+ {repair.repairType ? (
+
+ {REPAIR_TYPE_LABEL[repair.repairType]}
+
+ ) : '-'}
+
+
+ ))}
+
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/settings/PopupManagement/PopupDetail.tsx b/src/components/settings/PopupManagement/PopupDetail.tsx
index f263c24e..7250e626 100644
--- a/src/components/settings/PopupManagement/PopupDetail.tsx
+++ b/src/components/settings/PopupManagement/PopupDetail.tsx
@@ -10,7 +10,8 @@
*/
import { useRouter } from 'next/navigation';
-import { Megaphone, ArrowLeft, Edit, Trash2 } from 'lucide-react';
+import { Megaphone, X, Pencil, Trash2 } from 'lucide-react';
+import { useMenuStore } from '@/stores/menuStore';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { Button } from '@/components/ui/button';
@@ -27,6 +28,7 @@ interface PopupDetailProps {
export function PopupDetail({ popup, onEdit, onDelete }: PopupDetailProps) {
const router = useRouter();
+ const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed);
const handleBack = () => {
router.push('/ko/settings/popup-management');
@@ -100,22 +102,23 @@ export function PopupDetail({ popup, onEdit, onDelete }: PopupDetailProps) {
- {/* 버튼 영역 */}
-
+
+ {/* 하단 버튼 (sticky 하단 바) */}
+
+
+
+
+
-
-
-
-
diff --git a/src/components/settings/PopupManagement/PopupForm.tsx b/src/components/settings/PopupManagement/PopupForm.tsx
index 449efb36..750150ee 100644
--- a/src/components/settings/PopupManagement/PopupForm.tsx
+++ b/src/components/settings/PopupManagement/PopupForm.tsx
@@ -11,7 +11,8 @@
import { useState, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { format } from 'date-fns';
-import { Megaphone, ArrowLeft, Save, Loader2 } from 'lucide-react';
+import { Megaphone, X, Save, Loader2 } from 'lucide-react';
+import { useMenuStore } from '@/stores/menuStore';
import { createPopup, updatePopup } from './actions';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
@@ -62,6 +63,7 @@ function getLoggedInUserName(): string {
export function PopupForm({ mode, initialData }: PopupFormProps) {
const router = useRouter();
+ const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed);
// ===== 폼 상태 =====
const [target, setTarget] = useState(initialData?.target || 'all');
@@ -301,21 +303,22 @@ export function PopupForm({ mode, initialData }: PopupFormProps) {
)}
- {/* 버튼 영역 */}
-
-
-
-
+
+
+ {/* 하단 버튼 (sticky 하단 바) */}
+
+
+
);
diff --git a/src/components/templates/IntegratedListTemplateV2.tsx b/src/components/templates/IntegratedListTemplateV2.tsx
index 1fec1a2b..44a115c1 100644
--- a/src/components/templates/IntegratedListTemplateV2.tsx
+++ b/src/components/templates/IntegratedListTemplateV2.tsx
@@ -29,6 +29,18 @@ import { MobileFilter, FilterFieldConfig, FilterValues } from "@/components/mole
import { formatNumber } from '@/lib/utils/amount';
import { CopyableCell } from '@/components/molecules';
+// 렌더링된 React 노드에서 텍스트만 추출 (fallback 표시값 복사 대응)
+function extractTextFromNode(node: ReactNode): string {
+ if (typeof node === 'string') return node;
+ if (typeof node === 'number') return String(node);
+ if (!node) return '';
+ if (Array.isArray(node)) return node.map(extractTextFromNode).join('');
+ if (isValidElement(node)) {
+ return extractTextFromNode((node.props as { children?: ReactNode }).children);
+ }
+ return '';
+}
+
/**
* 기본 통합 목록_버젼2
*
@@ -96,7 +108,7 @@ export interface DevMetadata {
export interface IntegratedListTemplateV2Props
{
// 페이지 헤더
- title: string;
+ title: string | ReactNode;
description?: string;
icon?: LucideIcon;
headerActions?: ReactNode;
@@ -339,8 +351,12 @@ export function IntegratedListTemplateV2({
if (!colKey || !isValidElement(cell)) return cell;
const rawValue = (item as Record)[colKey];
- const copyValue = rawValue != null ? String(rawValue) : '';
- if (!copyValue) return cell;
+ let copyValue = rawValue != null ? String(rawValue) : '';
+ // 데이터 키가 비어있으면 렌더링된 셀 텍스트를 복사값으로 사용 (fallback 표시값 대응)
+ if (!copyValue) {
+ copyValue = extractTextFromNode(cell).trim();
+ }
+ if (!copyValue || copyValue === '-') return cell;
const cellEl = cell as React.ReactElement<{ children?: ReactNode }>;
return cloneElement(cellEl, {},
@@ -1015,7 +1031,9 @@ export function IntegratedListTemplateV2({
key={column.key}
className={`${column.className || ''} ${columnSettings ? 'relative' : ''}`}
>
- {column.key === "actions" && selectedItems.size === 0 ? "" : (
+ {column.key === "actions" ? (
+ {column.label}
+ ) : (
s.resetPageSettings);
const visibleColumns = useMemo(() => {
- return columns.filter((col) => !settings.hiddenColumns.includes(col.key));
- }, [columns, settings.hiddenColumns]);
+ return columns.filter((col) =>
+ alwaysVisibleKeys.includes(col.key) || !settings.hiddenColumns.includes(col.key)
+ );
+ }, [columns, settings.hiddenColumns, alwaysVisibleKeys]);
const allColumnsWithVisibility = useMemo((): ColumnWithVisibility[] => {
return columns.map((col) => ({
...col,
- visible: !settings.hiddenColumns.includes(col.key),
+ visible: alwaysVisibleKeys.includes(col.key) || !settings.hiddenColumns.includes(col.key),
locked: alwaysVisibleKeys.includes(col.key),
}));
}, [columns, settings.hiddenColumns, alwaysVisibleKeys]);