- Integrated monitoring settings and theme management into the Equipment, Production, and Quality monitoring pages. - Updated auto-refresh functionality to utilize user-defined settings for refresh intervals. - Improved UI elements with dynamic theming for better visual consistency across COMPANY_10, COMPANY_16, and COMPANY_29. - Added settings button to access monitoring configuration, enhancing user experience in managing monitoring preferences. These changes aim to provide a more customizable and user-friendly interface for monitoring operations across multiple companies.
595 lines
21 KiB
TypeScript
595 lines
21 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState } from "react";
|
|
import { useMonitoringSettingsAll } from "@/hooks/useMonitoringSettings";
|
|
import type {
|
|
MonitoringTheme,
|
|
ProductionLayout,
|
|
RefreshInterval,
|
|
AllMonitoringSettings,
|
|
} from "@/types/monitoringSettings";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { cn } from "@/lib/utils";
|
|
import { Settings2, Save, RotateCcw, Factory, Wrench, ClipboardCheck } from "lucide-react";
|
|
|
|
// ─── 탭 타입 ─────────────────────────────────────────────────
|
|
|
|
type MonitorTab = "production" | "equipment" | "quality";
|
|
|
|
const TABS: { key: MonitorTab; label: string; icon: React.ReactNode; desc: string }[] = [
|
|
{ key: "production", label: "생산모니터링", icon: <Factory className="h-6 w-6" />, desc: "작업지시 진행현황" },
|
|
{ key: "equipment", label: "설비운영모니터링", icon: <Wrench className="h-6 w-6" />, desc: "설비 가동 현황" },
|
|
{ key: "quality", label: "품질점검현황", icon: <ClipboardCheck className="h-6 w-6" />, desc: "검사 현황 모니터링" },
|
|
];
|
|
|
|
// ─── 필드 라벨 매핑 ──────────────────────────────────────────
|
|
|
|
const PRODUCTION_FIELD_LABELS: Record<string, string> = {
|
|
workInstructionNo: "작업지시번호",
|
|
itemName: "품목명",
|
|
spec: "규격",
|
|
customerName: "거래처",
|
|
worker: "작업자/작업조",
|
|
dueDate: "납기일",
|
|
equipment: "사용설비",
|
|
processProgress: "공정 진행현황",
|
|
progressBar: "진행률 바",
|
|
priority: "우선순위 표시",
|
|
salesOrderNo: "수주번호",
|
|
quantityInfo: "지시수량/완료수량",
|
|
};
|
|
|
|
const EQUIPMENT_FIELD_LABELS: Record<string, string> = {
|
|
equipmentName: "설비명",
|
|
equipmentType: "설비유형",
|
|
equipmentLocation: "설비위치",
|
|
operationStatus: "가동상태",
|
|
utilizationBar: "가동률 바",
|
|
dailyOperationTime: "금일 가동시간",
|
|
dailyProductionQty: "금일 생산수량",
|
|
worker: "작업자",
|
|
currentWorkInstruction: "현재 작업지시",
|
|
sensorData: "센서 데이터 (온도/압력/RPM)",
|
|
cumulativeOperationTime: "누적 가동시간",
|
|
nextInspectionDate: "다음 점검 예정일",
|
|
};
|
|
|
|
const QUALITY_COLUMN_LABELS: Record<string, string> = {
|
|
inspectionNo: "검사번호",
|
|
inspectionType: "검사유형",
|
|
itemName: "품목명",
|
|
spec: "규격",
|
|
inspectionQty: "검사수량",
|
|
passFailQty: "합격/불합격 수량",
|
|
defectRate: "불량률",
|
|
resultBar: "검사결과 바",
|
|
judgment: "판정",
|
|
inspector: "검사자",
|
|
inspectedAt: "검사일시",
|
|
inspectionCriteria: "검사기준",
|
|
};
|
|
|
|
const QUALITY_INSPECTION_LABELS: Record<string, string> = {
|
|
incoming: "입고검사",
|
|
process: "공정검사",
|
|
shipping: "출하검사",
|
|
};
|
|
|
|
// ─── 메인 컴포넌트 ───────────────────────────────────────────
|
|
|
|
export default function MonitoringSettingsPage() {
|
|
const { settings, setSettings, saveAll, resetAll, isLoaded } = useMonitoringSettingsAll();
|
|
const [activeTab, setActiveTab] = useState<MonitorTab>("production");
|
|
const [saved, setSaved] = useState(false);
|
|
|
|
const handleSave = () => {
|
|
saveAll();
|
|
setSaved(true);
|
|
setTimeout(() => setSaved(false), 2000);
|
|
};
|
|
|
|
const handleReset = () => {
|
|
if (!window.confirm("설정을 초기화하시겠습니까?")) return;
|
|
resetAll();
|
|
};
|
|
|
|
if (!isLoaded) {
|
|
return <div className="text-muted-foreground flex h-64 items-center justify-center">설정을 불러오는 중...</div>;
|
|
}
|
|
|
|
return (
|
|
<div className="bg-background flex h-full min-h-0 flex-col overflow-auto">
|
|
<div className="mx-auto w-full max-w-[1200px] space-y-6 p-6">
|
|
{/* 헤더 */}
|
|
<div>
|
|
<h1 className="text-foreground flex items-center gap-2 text-xl font-bold">
|
|
<Settings2 className="h-5 w-5" />
|
|
모니터링 설정
|
|
</h1>
|
|
<p className="text-muted-foreground mt-1 text-sm">
|
|
각 모니터링 화면의 디자인, 레이아웃, 표시 항목을 설정할 수 있습니다.
|
|
</p>
|
|
</div>
|
|
|
|
{/* 모니터링 선택 탭 */}
|
|
<div className="grid grid-cols-3 gap-3">
|
|
{TABS.map((tab) => (
|
|
<button
|
|
key={tab.key}
|
|
onClick={() => setActiveTab(tab.key)}
|
|
className={cn(
|
|
"flex flex-col items-center gap-2 rounded-lg border-2 p-4 text-center transition-all",
|
|
activeTab === tab.key ? "border-primary bg-primary/5" : "border-border hover:border-primary/50",
|
|
)}
|
|
>
|
|
<div className="text-primary">{tab.icon}</div>
|
|
<div className="text-foreground text-sm font-bold">{tab.label}</div>
|
|
<div className="text-muted-foreground text-xs">{tab.desc}</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* 설정 내용 */}
|
|
{activeTab === "production" && <ProductionSettings settings={settings} setSettings={setSettings} />}
|
|
{activeTab === "equipment" && <EquipmentSettings settings={settings} setSettings={setSettings} />}
|
|
{activeTab === "quality" && <QualitySettings settings={settings} setSettings={setSettings} />}
|
|
|
|
{/* 하단 저장 바 */}
|
|
<div className="bg-card border-border sticky bottom-0 flex justify-end gap-3 border-t py-4">
|
|
<Button variant="outline" onClick={handleReset}>
|
|
<RotateCcw className="mr-1.5 h-4 w-4" />
|
|
초기화
|
|
</Button>
|
|
<Button onClick={handleSave}>
|
|
<Save className="mr-1.5 h-4 w-4" />
|
|
{saved ? "저장 완료!" : "설정 저장"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── 테마 선택기 ─────────────────────────────────────────────
|
|
|
|
function ThemeSelector({ value, onChange }: { value: MonitoringTheme; onChange: (theme: MonitoringTheme) => void }) {
|
|
const themes: { key: MonitoringTheme; label: string; preview: string; bg: string }[] = [
|
|
{ key: "dark", label: "다크 모드", preview: "bg-gray-900", bg: "bg-gray-900" },
|
|
{ key: "blue", label: "딥 블루", preview: "bg-slate-800", bg: "bg-slate-800" },
|
|
{ key: "light", label: "라이트 모드", preview: "bg-gray-100 border border-gray-200", bg: "bg-gray-100" },
|
|
];
|
|
|
|
return (
|
|
<div className="grid grid-cols-3 gap-3">
|
|
{themes.map((t) => (
|
|
<button
|
|
key={t.key}
|
|
onClick={() => onChange(t.key)}
|
|
className={cn(
|
|
"rounded-lg border-2 p-4 text-center transition-all",
|
|
value === t.key
|
|
? "border-primary ring-primary/15 shadow-sm ring-2"
|
|
: "border-border hover:border-primary/50",
|
|
)}
|
|
>
|
|
<div className={cn("mb-2 flex h-16 items-center justify-center rounded-md text-xl", t.preview)}>
|
|
{t.key === "dark" ? "🌙" : t.key === "blue" ? "🌊" : "☀️"}
|
|
</div>
|
|
<div className="text-foreground text-xs font-bold">{t.label}</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── 레이아웃 선택기 (생산만) ────────────────────────────────
|
|
|
|
function LayoutSelector({
|
|
value,
|
|
onChange,
|
|
}: {
|
|
value: ProductionLayout;
|
|
onChange: (layout: ProductionLayout) => void;
|
|
}) {
|
|
const layouts: { key: ProductionLayout; label: string }[] = [
|
|
{ key: "grid", label: "그리드형" },
|
|
{ key: "list", label: "리스트형" },
|
|
{ key: "split", label: "분할형" },
|
|
];
|
|
|
|
return (
|
|
<div className="grid grid-cols-3 gap-3">
|
|
{layouts.map((l) => (
|
|
<button
|
|
key={l.key}
|
|
onClick={() => onChange(l.key)}
|
|
className={cn(
|
|
"rounded-lg border-2 p-3 text-center transition-all",
|
|
value === l.key
|
|
? "border-primary ring-primary/15 shadow-sm ring-2"
|
|
: "border-border hover:border-primary/50",
|
|
)}
|
|
>
|
|
<div className="bg-muted mb-2 flex h-12 gap-1 rounded-md p-1.5">
|
|
{l.key === "grid" && (
|
|
<>
|
|
<div className="bg-primary/20 flex-1 rounded" />
|
|
<div className="bg-primary/20 flex-1 rounded" />
|
|
<div className="bg-primary/20 flex-1 rounded" />
|
|
</>
|
|
)}
|
|
{l.key === "list" && (
|
|
<div className="flex w-full flex-col gap-1">
|
|
<div className="bg-primary/20 h-3 rounded" />
|
|
<div className="bg-primary/20 h-3 rounded" />
|
|
<div className="bg-primary/20 h-3 rounded" />
|
|
</div>
|
|
)}
|
|
{l.key === "split" && (
|
|
<>
|
|
<div className="bg-primary/20 w-[40%] rounded" />
|
|
<div className="bg-primary/20 flex-1 rounded" />
|
|
</>
|
|
)}
|
|
</div>
|
|
<div className="text-foreground text-xs font-semibold">{l.label}</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── 갱신 설정 섹션 ──────────────────────────────────────────
|
|
|
|
function RefreshSettings({
|
|
refreshInterval,
|
|
autoRefresh,
|
|
soundOrAlarm,
|
|
soundOrAlarmLabel,
|
|
onIntervalChange,
|
|
onAutoRefreshChange,
|
|
onSoundOrAlarmChange,
|
|
}: {
|
|
refreshInterval: RefreshInterval;
|
|
autoRefresh: boolean;
|
|
soundOrAlarm: boolean;
|
|
soundOrAlarmLabel: string;
|
|
onIntervalChange: (v: RefreshInterval) => void;
|
|
onAutoRefreshChange: (v: boolean) => void;
|
|
onSoundOrAlarmChange: (v: boolean) => void;
|
|
}) {
|
|
return (
|
|
<div className="grid grid-cols-3 gap-4">
|
|
<div className="space-y-2">
|
|
<Label>자동 갱신 주기</Label>
|
|
<Select value={String(refreshInterval)} onValueChange={(v) => onIntervalChange(Number(v) as RefreshInterval)}>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="10">10초</SelectItem>
|
|
<SelectItem value="30">30초</SelectItem>
|
|
<SelectItem value="60">1분</SelectItem>
|
|
<SelectItem value="300">5분</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>자동 갱신</Label>
|
|
<div className="flex items-center gap-2 pt-1">
|
|
<Switch checked={autoRefresh} onCheckedChange={onAutoRefreshChange} />
|
|
<span className="text-muted-foreground text-sm">{autoRefresh ? "사용" : "사용안함"}</span>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>{soundOrAlarmLabel}</Label>
|
|
<div className="flex items-center gap-2 pt-1">
|
|
<Switch checked={soundOrAlarm} onCheckedChange={onSoundOrAlarmChange} />
|
|
<span className="text-muted-foreground text-sm">{soundOrAlarm ? "사용" : "사용안함"}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── 체크박스 그리드 ─────────────────────────────────────────
|
|
|
|
function FieldCheckboxGrid({
|
|
fields,
|
|
labels,
|
|
onChange,
|
|
}: {
|
|
fields: Record<string, boolean>;
|
|
labels: Record<string, string>;
|
|
onChange: (key: string, checked: boolean) => void;
|
|
}) {
|
|
const allChecked = Object.values(fields).every(Boolean);
|
|
const noneChecked = Object.values(fields).every((v) => !v);
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => Object.keys(fields).forEach((k) => onChange(k, true))}
|
|
disabled={allChecked}
|
|
>
|
|
전체선택
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => Object.keys(fields).forEach((k) => onChange(k, false))}
|
|
disabled={noneChecked}
|
|
>
|
|
전체해제
|
|
</Button>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-4">
|
|
{Object.entries(fields).map(([key, checked]) => (
|
|
<label
|
|
key={key}
|
|
className={cn(
|
|
"flex cursor-pointer items-center gap-2 rounded-md border px-3 py-2.5 transition-all select-none",
|
|
checked ? "bg-primary/5 border-primary/30" : "bg-background border-border hover:border-primary/30",
|
|
)}
|
|
>
|
|
<Checkbox checked={checked} onCheckedChange={(v) => onChange(key, v === true)} />
|
|
<span className="text-foreground text-sm font-medium">{labels[key] || key}</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── 생산모니터링 설정 ───────────────────────────────────────
|
|
|
|
function ProductionSettings({
|
|
settings,
|
|
setSettings,
|
|
}: {
|
|
settings: AllMonitoringSettings;
|
|
setSettings: React.Dispatch<React.SetStateAction<AllMonitoringSettings>>;
|
|
}) {
|
|
const prod = settings.production;
|
|
|
|
const update = (partial: Partial<typeof prod>) => {
|
|
setSettings((prev) => ({
|
|
...prev,
|
|
production: { ...prev.production, ...partial },
|
|
}));
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">테마 설정</CardTitle>
|
|
<CardDescription>모니터링 화면의 배경 테마를 선택합니다</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<ThemeSelector value={prod.theme} onChange={(theme) => update({ theme })} />
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">레이아웃</CardTitle>
|
|
<CardDescription>카드 배치 방식을 선택합니다</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<LayoutSelector value={prod.layout} onChange={(layout) => update({ layout })} />
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">갱신 설정</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<RefreshSettings
|
|
refreshInterval={prod.refreshInterval}
|
|
autoRefresh={prod.autoRefresh}
|
|
soundOrAlarm={prod.soundEnabled}
|
|
soundOrAlarmLabel="알림음"
|
|
onIntervalChange={(refreshInterval) => update({ refreshInterval })}
|
|
onAutoRefreshChange={(autoRefresh) => update({ autoRefresh })}
|
|
onSoundOrAlarmChange={(soundEnabled) => update({ soundEnabled })}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">표시 항목</CardTitle>
|
|
<CardDescription>작업 카드에 표시할 정보를 선택합니다</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<FieldCheckboxGrid
|
|
fields={prod.displayFields}
|
|
labels={PRODUCTION_FIELD_LABELS}
|
|
onChange={(key, checked) =>
|
|
update({
|
|
displayFields: { ...prod.displayFields, [key]: checked },
|
|
})
|
|
}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── 설비모니터링 설정 ───────────────────────────────────────
|
|
|
|
function EquipmentSettings({
|
|
settings,
|
|
setSettings,
|
|
}: {
|
|
settings: AllMonitoringSettings;
|
|
setSettings: React.Dispatch<React.SetStateAction<AllMonitoringSettings>>;
|
|
}) {
|
|
const equip = settings.equipment;
|
|
|
|
const update = (partial: Partial<typeof equip>) => {
|
|
setSettings((prev) => ({
|
|
...prev,
|
|
equipment: { ...prev.equipment, ...partial },
|
|
}));
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">테마 설정</CardTitle>
|
|
<CardDescription>모니터링 화면의 배경 테마를 선택합니다</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<ThemeSelector value={equip.theme} onChange={(theme) => update({ theme })} />
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">갱신 설정</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<RefreshSettings
|
|
refreshInterval={equip.refreshInterval}
|
|
autoRefresh={equip.autoRefresh}
|
|
soundOrAlarm={equip.alarmEnabled}
|
|
soundOrAlarmLabel="이상 알림"
|
|
onIntervalChange={(refreshInterval) => update({ refreshInterval })}
|
|
onAutoRefreshChange={(autoRefresh) => update({ autoRefresh })}
|
|
onSoundOrAlarmChange={(alarmEnabled) => update({ alarmEnabled })}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">표시 항목</CardTitle>
|
|
<CardDescription>설비 카드에 표시할 정보를 선택합니다</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<FieldCheckboxGrid
|
|
fields={equip.displayFields}
|
|
labels={EQUIPMENT_FIELD_LABELS}
|
|
onChange={(key, checked) =>
|
|
update({
|
|
displayFields: { ...equip.displayFields, [key]: checked },
|
|
})
|
|
}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── 품질모니터링 설정 ───────────────────────────────────────
|
|
|
|
function QualitySettings({
|
|
settings,
|
|
setSettings,
|
|
}: {
|
|
settings: AllMonitoringSettings;
|
|
setSettings: React.Dispatch<React.SetStateAction<AllMonitoringSettings>>;
|
|
}) {
|
|
const qual = settings.quality;
|
|
|
|
const update = (partial: Partial<typeof qual>) => {
|
|
setSettings((prev) => ({
|
|
...prev,
|
|
quality: { ...prev.quality, ...partial },
|
|
}));
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">테마 설정</CardTitle>
|
|
<CardDescription>모니터링 화면의 배경 테마를 선택합니다</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<ThemeSelector value={qual.theme} onChange={(theme) => update({ theme })} />
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">갱신 설정</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<RefreshSettings
|
|
refreshInterval={qual.refreshInterval}
|
|
autoRefresh={qual.autoRefresh}
|
|
soundOrAlarm={qual.alarmEnabled}
|
|
soundOrAlarmLabel="불합격 알림"
|
|
onIntervalChange={(refreshInterval) => update({ refreshInterval })}
|
|
onAutoRefreshChange={(autoRefresh) => update({ autoRefresh })}
|
|
onSoundOrAlarmChange={(alarmEnabled) => update({ alarmEnabled })}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">검사유형 표시</CardTitle>
|
|
<CardDescription>모니터링에 표시할 검사유형을 선택합니다</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="grid grid-cols-3 gap-2">
|
|
{Object.entries(qual.inspectionTypes).map(([key, checked]) => (
|
|
<label
|
|
key={key}
|
|
className={cn(
|
|
"flex cursor-pointer items-center gap-2 rounded-md border px-3 py-2.5 transition-all select-none",
|
|
checked ? "bg-primary/5 border-primary/30" : "bg-background border-border hover:border-primary/30",
|
|
)}
|
|
>
|
|
<Checkbox
|
|
checked={checked}
|
|
onCheckedChange={(v) =>
|
|
update({
|
|
inspectionTypes: { ...qual.inspectionTypes, [key]: v === true },
|
|
})
|
|
}
|
|
/>
|
|
<span className="text-foreground text-sm font-medium">{QUALITY_INSPECTION_LABELS[key] || key}</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">테이블 컬럼</CardTitle>
|
|
<CardDescription>검사 테이블에 표시할 컬럼을 선택합니다</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<FieldCheckboxGrid
|
|
fields={qual.tableColumns}
|
|
labels={QUALITY_COLUMN_LABELS}
|
|
onChange={(key, checked) =>
|
|
update({
|
|
tableColumns: { ...qual.tableColumns, [key]: checked },
|
|
})
|
|
}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|