Merge remote-tracking branch 'origin/main' into ksh
This commit is contained in:
commit
384106dd95
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
|
|
@ -2720,28 +2720,48 @@ export class NodeFlowExecutionService {
|
||||||
const trueData: any[] = [];
|
const trueData: any[] = [];
|
||||||
const falseData: any[] = [];
|
const falseData: any[] = [];
|
||||||
|
|
||||||
inputData.forEach((item: any) => {
|
// 배열의 각 항목에 대해 조건 평가 (EXISTS 조건은 비동기)
|
||||||
const results = conditions.map((condition: any) => {
|
for (const item of inputData) {
|
||||||
|
const results: boolean[] = [];
|
||||||
|
|
||||||
|
for (const condition of conditions) {
|
||||||
const fieldValue = item[condition.field];
|
const fieldValue = item[condition.field];
|
||||||
|
|
||||||
let compareValue = condition.value;
|
// EXISTS 계열 연산자 처리
|
||||||
if (condition.valueType === "field") {
|
if (
|
||||||
compareValue = item[condition.value];
|
condition.operator === "EXISTS_IN" ||
|
||||||
|
condition.operator === "NOT_EXISTS_IN"
|
||||||
|
) {
|
||||||
|
const existsResult = await this.evaluateExistsCondition(
|
||||||
|
fieldValue,
|
||||||
|
condition.operator,
|
||||||
|
condition.lookupTable,
|
||||||
|
condition.lookupField,
|
||||||
|
context.buttonContext?.companyCode
|
||||||
|
);
|
||||||
|
results.push(existsResult);
|
||||||
logger.info(
|
logger.info(
|
||||||
`🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})`
|
`🔍 EXISTS 조건: ${condition.field} (${fieldValue}) ${condition.operator} ${condition.lookupTable}.${condition.lookupField} => ${existsResult}`
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
logger.info(
|
// 일반 연산자 처리
|
||||||
`📊 고정값 비교: ${condition.field} (${fieldValue}) vs ${compareValue}`
|
let compareValue = condition.value;
|
||||||
|
if (condition.valueType === "field") {
|
||||||
|
compareValue = item[condition.value];
|
||||||
|
logger.info(
|
||||||
|
`🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.info(
|
||||||
|
`📊 고정값 비교: ${condition.field} (${fieldValue}) vs ${compareValue}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push(
|
||||||
|
this.evaluateCondition(fieldValue, condition.operator, compareValue)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return this.evaluateCondition(
|
|
||||||
fieldValue,
|
|
||||||
condition.operator,
|
|
||||||
compareValue
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const result =
|
const result =
|
||||||
logic === "OR"
|
logic === "OR"
|
||||||
|
|
@ -2753,7 +2773,7 @@ export class NodeFlowExecutionService {
|
||||||
} else {
|
} else {
|
||||||
falseData.push(item);
|
falseData.push(item);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`🔍 조건 필터링 결과: TRUE ${trueData.length}건 / FALSE ${falseData.length}건 (${logic} 로직)`
|
`🔍 조건 필터링 결과: TRUE ${trueData.length}건 / FALSE ${falseData.length}건 (${logic} 로직)`
|
||||||
|
|
@ -2768,27 +2788,46 @@ export class NodeFlowExecutionService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 단일 객체인 경우
|
// 단일 객체인 경우
|
||||||
const results = conditions.map((condition: any) => {
|
const results: boolean[] = [];
|
||||||
|
|
||||||
|
for (const condition of conditions) {
|
||||||
const fieldValue = inputData[condition.field];
|
const fieldValue = inputData[condition.field];
|
||||||
|
|
||||||
let compareValue = condition.value;
|
// EXISTS 계열 연산자 처리
|
||||||
if (condition.valueType === "field") {
|
if (
|
||||||
compareValue = inputData[condition.value];
|
condition.operator === "EXISTS_IN" ||
|
||||||
|
condition.operator === "NOT_EXISTS_IN"
|
||||||
|
) {
|
||||||
|
const existsResult = await this.evaluateExistsCondition(
|
||||||
|
fieldValue,
|
||||||
|
condition.operator,
|
||||||
|
condition.lookupTable,
|
||||||
|
condition.lookupField,
|
||||||
|
context.buttonContext?.companyCode
|
||||||
|
);
|
||||||
|
results.push(existsResult);
|
||||||
logger.info(
|
logger.info(
|
||||||
`🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})`
|
`🔍 EXISTS 조건: ${condition.field} (${fieldValue}) ${condition.operator} ${condition.lookupTable}.${condition.lookupField} => ${existsResult}`
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
logger.info(
|
// 일반 연산자 처리
|
||||||
`📊 고정값 비교: ${condition.field} (${fieldValue}) vs ${compareValue}`
|
let compareValue = condition.value;
|
||||||
|
if (condition.valueType === "field") {
|
||||||
|
compareValue = inputData[condition.value];
|
||||||
|
logger.info(
|
||||||
|
`🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.info(
|
||||||
|
`📊 고정값 비교: ${condition.field} (${fieldValue}) vs ${compareValue}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push(
|
||||||
|
this.evaluateCondition(fieldValue, condition.operator, compareValue)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return this.evaluateCondition(
|
|
||||||
fieldValue,
|
|
||||||
condition.operator,
|
|
||||||
compareValue
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const result =
|
const result =
|
||||||
logic === "OR"
|
logic === "OR"
|
||||||
|
|
@ -2797,7 +2836,7 @@ export class NodeFlowExecutionService {
|
||||||
|
|
||||||
logger.info(`🔍 조건 평가 결과: ${result} (${logic} 로직)`);
|
logger.info(`🔍 조건 평가 결과: ${result} (${logic} 로직)`);
|
||||||
|
|
||||||
// ⚠️ 조건 노드는 TRUE/FALSE 브랜치를 위한 특별한 처리 필요
|
// 조건 노드는 TRUE/FALSE 브랜치를 위한 특별한 처리 필요
|
||||||
// 조건 결과를 저장하고, 원본 데이터는 항상 반환
|
// 조건 결과를 저장하고, 원본 데이터는 항상 반환
|
||||||
// 다음 노드에서 sourceHandle을 기반으로 필터링됨
|
// 다음 노드에서 sourceHandle을 기반으로 필터링됨
|
||||||
return {
|
return {
|
||||||
|
|
@ -2808,6 +2847,68 @@ export class NodeFlowExecutionService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EXISTS_IN / NOT_EXISTS_IN 조건 평가
|
||||||
|
* 다른 테이블에 값이 존재하는지 확인
|
||||||
|
*/
|
||||||
|
private static async evaluateExistsCondition(
|
||||||
|
fieldValue: any,
|
||||||
|
operator: string,
|
||||||
|
lookupTable: string,
|
||||||
|
lookupField: string,
|
||||||
|
companyCode?: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!lookupTable || !lookupField) {
|
||||||
|
logger.warn("⚠️ EXISTS 조건: lookupTable 또는 lookupField가 없습니다");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldValue === null || fieldValue === undefined || fieldValue === "") {
|
||||||
|
logger.info(
|
||||||
|
`⚠️ EXISTS 조건: 필드값이 비어있어 ${operator === "NOT_EXISTS_IN" ? "TRUE" : "FALSE"} 반환`
|
||||||
|
);
|
||||||
|
// 값이 비어있으면: EXISTS_IN은 false, NOT_EXISTS_IN은 true
|
||||||
|
return operator === "NOT_EXISTS_IN";
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 멀티테넌시: company_code 필터 적용 여부 확인
|
||||||
|
// company_mng 테이블은 제외
|
||||||
|
const hasCompanyCode = lookupTable !== "company_mng" && companyCode;
|
||||||
|
|
||||||
|
let sql: string;
|
||||||
|
let params: any[];
|
||||||
|
|
||||||
|
if (hasCompanyCode) {
|
||||||
|
sql = `SELECT EXISTS(SELECT 1 FROM "${lookupTable}" WHERE "${lookupField}" = $1 AND company_code = $2) as exists_result`;
|
||||||
|
params = [fieldValue, companyCode];
|
||||||
|
} else {
|
||||||
|
sql = `SELECT EXISTS(SELECT 1 FROM "${lookupTable}" WHERE "${lookupField}" = $1) as exists_result`;
|
||||||
|
params = [fieldValue];
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`🔍 EXISTS 쿼리: ${sql}, params: ${JSON.stringify(params)}`);
|
||||||
|
|
||||||
|
const result = await query(sql, params);
|
||||||
|
const existsInTable = result[0]?.exists_result === true;
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`🔍 EXISTS 결과: ${fieldValue}이(가) ${lookupTable}.${lookupField}에 ${existsInTable ? "존재함" : "존재하지 않음"}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// EXISTS_IN: 존재하면 true
|
||||||
|
// NOT_EXISTS_IN: 존재하지 않으면 true
|
||||||
|
if (operator === "EXISTS_IN") {
|
||||||
|
return existsInTable;
|
||||||
|
} else {
|
||||||
|
return !existsInTable;
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`❌ EXISTS 조건 평가 실패: ${error.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WHERE 절 생성
|
* WHERE 절 생성
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,10 @@ import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Search, Database, RefreshCw, Settings, Plus, Activity, Trash2, Copy } from "lucide-react";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||||
|
import { Search, Database, RefreshCw, Settings, Plus, Activity, Trash2, Copy, Check, ChevronsUpDown } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useMultiLang } from "@/hooks/useMultiLang";
|
import { useMultiLang } from "@/hooks/useMultiLang";
|
||||||
|
|
@ -90,6 +93,13 @@ export default function TableManagementPage() {
|
||||||
// 🎯 Entity 조인 관련 상태
|
// 🎯 Entity 조인 관련 상태
|
||||||
const [referenceTableColumns, setReferenceTableColumns] = useState<Record<string, ReferenceTableColumn[]>>({});
|
const [referenceTableColumns, setReferenceTableColumns] = useState<Record<string, ReferenceTableColumn[]>>({});
|
||||||
|
|
||||||
|
// 🆕 Entity 타입 Combobox 열림/닫힘 상태 (컬럼별 관리)
|
||||||
|
const [entityComboboxOpen, setEntityComboboxOpen] = useState<Record<string, {
|
||||||
|
table: boolean;
|
||||||
|
joinColumn: boolean;
|
||||||
|
displayColumn: boolean;
|
||||||
|
}>>({});
|
||||||
|
|
||||||
// DDL 기능 관련 상태
|
// DDL 기능 관련 상태
|
||||||
const [createTableModalOpen, setCreateTableModalOpen] = useState(false);
|
const [createTableModalOpen, setCreateTableModalOpen] = useState(false);
|
||||||
const [addColumnModalOpen, setAddColumnModalOpen] = useState(false);
|
const [addColumnModalOpen, setAddColumnModalOpen] = useState(false);
|
||||||
|
|
@ -1388,113 +1398,266 @@ export default function TableManagementPage() {
|
||||||
{/* 입력 타입이 'entity'인 경우 참조 테이블 선택 */}
|
{/* 입력 타입이 'entity'인 경우 참조 테이블 선택 */}
|
||||||
{column.inputType === "entity" && (
|
{column.inputType === "entity" && (
|
||||||
<>
|
<>
|
||||||
{/* 참조 테이블 */}
|
{/* 참조 테이블 - 검색 가능한 Combobox */}
|
||||||
<div className="w-48">
|
<div className="w-56">
|
||||||
<label className="text-muted-foreground mb-1 block text-xs">참조 테이블</label>
|
<label className="text-muted-foreground mb-1 block text-xs">참조 테이블</label>
|
||||||
<Select
|
<Popover
|
||||||
value={column.referenceTable || "none"}
|
open={entityComboboxOpen[column.columnName]?.table || false}
|
||||||
onValueChange={(value) =>
|
onOpenChange={(open) =>
|
||||||
handleDetailSettingsChange(column.columnName, "entity", value)
|
setEntityComboboxOpen((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[column.columnName]: { ...prev[column.columnName], table: open },
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="bg-background h-8 w-full text-xs">
|
<PopoverTrigger asChild>
|
||||||
<SelectValue placeholder="선택" />
|
<Button
|
||||||
</SelectTrigger>
|
variant="outline"
|
||||||
<SelectContent>
|
role="combobox"
|
||||||
{referenceTableOptions.map((option, index) => (
|
aria-expanded={entityComboboxOpen[column.columnName]?.table || false}
|
||||||
<SelectItem key={`entity-${option.value}-${index}`} value={option.value}>
|
className="bg-background h-8 w-full justify-between text-xs"
|
||||||
<div className="flex flex-col">
|
>
|
||||||
<span className="font-medium">{option.label}</span>
|
{column.referenceTable && column.referenceTable !== "none"
|
||||||
<span className="text-muted-foreground text-xs">{option.value}</span>
|
? referenceTableOptions.find((opt) => opt.value === column.referenceTable)?.label ||
|
||||||
</div>
|
column.referenceTable
|
||||||
</SelectItem>
|
: "테이블 선택..."}
|
||||||
))}
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
</SelectContent>
|
</Button>
|
||||||
</Select>
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[280px] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
|
||||||
|
<CommandList className="max-h-[200px]">
|
||||||
|
<CommandEmpty className="py-2 text-center text-xs">
|
||||||
|
테이블을 찾을 수 없습니다.
|
||||||
|
</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{referenceTableOptions.map((option) => (
|
||||||
|
<CommandItem
|
||||||
|
key={option.value}
|
||||||
|
value={`${option.label} ${option.value}`}
|
||||||
|
onSelect={() => {
|
||||||
|
handleDetailSettingsChange(column.columnName, "entity", option.value);
|
||||||
|
setEntityComboboxOpen((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[column.columnName]: { ...prev[column.columnName], table: false },
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-3 w-3",
|
||||||
|
column.referenceTable === option.value ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{option.label}</span>
|
||||||
|
{option.value !== "none" && (
|
||||||
|
<span className="text-muted-foreground text-[10px]">{option.value}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 조인 컬럼 */}
|
{/* 조인 컬럼 - 검색 가능한 Combobox */}
|
||||||
{column.referenceTable && column.referenceTable !== "none" && (
|
{column.referenceTable && column.referenceTable !== "none" && (
|
||||||
<div className="w-48">
|
<div className="w-56">
|
||||||
<label className="text-muted-foreground mb-1 block text-xs">조인 컬럼</label>
|
<label className="text-muted-foreground mb-1 block text-xs">조인 컬럼</label>
|
||||||
<Select
|
<Popover
|
||||||
value={column.referenceColumn || "none"}
|
open={entityComboboxOpen[column.columnName]?.joinColumn || false}
|
||||||
onValueChange={(value) =>
|
onOpenChange={(open) =>
|
||||||
handleDetailSettingsChange(
|
setEntityComboboxOpen((prev) => ({
|
||||||
column.columnName,
|
...prev,
|
||||||
"entity_reference_column",
|
[column.columnName]: { ...prev[column.columnName], joinColumn: open },
|
||||||
value,
|
}))
|
||||||
)
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="bg-background h-8 w-full text-xs">
|
<PopoverTrigger asChild>
|
||||||
<SelectValue placeholder="선택" />
|
<Button
|
||||||
</SelectTrigger>
|
variant="outline"
|
||||||
<SelectContent>
|
role="combobox"
|
||||||
<SelectItem value="none">-- 선택 안함 --</SelectItem>
|
aria-expanded={entityComboboxOpen[column.columnName]?.joinColumn || false}
|
||||||
{referenceTableColumns[column.referenceTable]?.map((refCol, index) => (
|
className="bg-background h-8 w-full justify-between text-xs"
|
||||||
<SelectItem
|
disabled={!referenceTableColumns[column.referenceTable] || referenceTableColumns[column.referenceTable].length === 0}
|
||||||
key={`ref-col-${refCol.columnName}-${index}`}
|
>
|
||||||
value={refCol.columnName}
|
{!referenceTableColumns[column.referenceTable] || referenceTableColumns[column.referenceTable].length === 0 ? (
|
||||||
>
|
<span className="flex items-center gap-2">
|
||||||
<span className="font-medium">{refCol.columnName}</span>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
{(!referenceTableColumns[column.referenceTable] ||
|
|
||||||
referenceTableColumns[column.referenceTable].length === 0) && (
|
|
||||||
<SelectItem value="loading" disabled>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="border-primary h-3 w-3 animate-spin rounded-full border border-t-transparent"></div>
|
<div className="border-primary h-3 w-3 animate-spin rounded-full border border-t-transparent"></div>
|
||||||
로딩중
|
로딩중...
|
||||||
</div>
|
</span>
|
||||||
</SelectItem>
|
) : column.referenceColumn && column.referenceColumn !== "none" ? (
|
||||||
)}
|
column.referenceColumn
|
||||||
</SelectContent>
|
) : (
|
||||||
</Select>
|
"컬럼 선택..."
|
||||||
|
)}
|
||||||
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[280px] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
|
||||||
|
<CommandList className="max-h-[200px]">
|
||||||
|
<CommandEmpty className="py-2 text-center text-xs">
|
||||||
|
컬럼을 찾을 수 없습니다.
|
||||||
|
</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
<CommandItem
|
||||||
|
value="none"
|
||||||
|
onSelect={() => {
|
||||||
|
handleDetailSettingsChange(column.columnName, "entity_reference_column", "none");
|
||||||
|
setEntityComboboxOpen((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[column.columnName]: { ...prev[column.columnName], joinColumn: false },
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-3 w-3",
|
||||||
|
column.referenceColumn === "none" || !column.referenceColumn ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
-- 선택 안함 --
|
||||||
|
</CommandItem>
|
||||||
|
{referenceTableColumns[column.referenceTable]?.map((refCol) => (
|
||||||
|
<CommandItem
|
||||||
|
key={refCol.columnName}
|
||||||
|
value={`${refCol.columnLabel || ""} ${refCol.columnName}`}
|
||||||
|
onSelect={() => {
|
||||||
|
handleDetailSettingsChange(column.columnName, "entity_reference_column", refCol.columnName);
|
||||||
|
setEntityComboboxOpen((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[column.columnName]: { ...prev[column.columnName], joinColumn: false },
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-3 w-3",
|
||||||
|
column.referenceColumn === refCol.columnName ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{refCol.columnName}</span>
|
||||||
|
{refCol.columnLabel && (
|
||||||
|
<span className="text-muted-foreground text-[10px]">{refCol.columnLabel}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 표시 컬럼 */}
|
{/* 표시 컬럼 - 검색 가능한 Combobox */}
|
||||||
{column.referenceTable &&
|
{column.referenceTable &&
|
||||||
column.referenceTable !== "none" &&
|
column.referenceTable !== "none" &&
|
||||||
column.referenceColumn &&
|
column.referenceColumn &&
|
||||||
column.referenceColumn !== "none" && (
|
column.referenceColumn !== "none" && (
|
||||||
<div className="w-48">
|
<div className="w-56">
|
||||||
<label className="text-muted-foreground mb-1 block text-xs">표시 컬럼</label>
|
<label className="text-muted-foreground mb-1 block text-xs">표시 컬럼</label>
|
||||||
<Select
|
<Popover
|
||||||
value={column.displayColumn || "none"}
|
open={entityComboboxOpen[column.columnName]?.displayColumn || false}
|
||||||
onValueChange={(value) =>
|
onOpenChange={(open) =>
|
||||||
handleDetailSettingsChange(
|
setEntityComboboxOpen((prev) => ({
|
||||||
column.columnName,
|
...prev,
|
||||||
"entity_display_column",
|
[column.columnName]: { ...prev[column.columnName], displayColumn: open },
|
||||||
value,
|
}))
|
||||||
)
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="bg-background h-8 w-full text-xs">
|
<PopoverTrigger asChild>
|
||||||
<SelectValue placeholder="선택" />
|
<Button
|
||||||
</SelectTrigger>
|
variant="outline"
|
||||||
<SelectContent>
|
role="combobox"
|
||||||
<SelectItem value="none">-- 선택 안함 --</SelectItem>
|
aria-expanded={entityComboboxOpen[column.columnName]?.displayColumn || false}
|
||||||
{referenceTableColumns[column.referenceTable]?.map((refCol, index) => (
|
className="bg-background h-8 w-full justify-between text-xs"
|
||||||
<SelectItem
|
disabled={!referenceTableColumns[column.referenceTable] || referenceTableColumns[column.referenceTable].length === 0}
|
||||||
key={`ref-col-${refCol.columnName}-${index}`}
|
>
|
||||||
value={refCol.columnName}
|
{!referenceTableColumns[column.referenceTable] || referenceTableColumns[column.referenceTable].length === 0 ? (
|
||||||
>
|
<span className="flex items-center gap-2">
|
||||||
<span className="font-medium">{refCol.columnName}</span>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
{(!referenceTableColumns[column.referenceTable] ||
|
|
||||||
referenceTableColumns[column.referenceTable].length === 0) && (
|
|
||||||
<SelectItem value="loading" disabled>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="border-primary h-3 w-3 animate-spin rounded-full border border-t-transparent"></div>
|
<div className="border-primary h-3 w-3 animate-spin rounded-full border border-t-transparent"></div>
|
||||||
로딩중
|
로딩중...
|
||||||
</div>
|
</span>
|
||||||
</SelectItem>
|
) : column.displayColumn && column.displayColumn !== "none" ? (
|
||||||
)}
|
column.displayColumn
|
||||||
</SelectContent>
|
) : (
|
||||||
</Select>
|
"컬럼 선택..."
|
||||||
|
)}
|
||||||
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[280px] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
|
||||||
|
<CommandList className="max-h-[200px]">
|
||||||
|
<CommandEmpty className="py-2 text-center text-xs">
|
||||||
|
컬럼을 찾을 수 없습니다.
|
||||||
|
</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
<CommandItem
|
||||||
|
value="none"
|
||||||
|
onSelect={() => {
|
||||||
|
handleDetailSettingsChange(column.columnName, "entity_display_column", "none");
|
||||||
|
setEntityComboboxOpen((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[column.columnName]: { ...prev[column.columnName], displayColumn: false },
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-3 w-3",
|
||||||
|
column.displayColumn === "none" || !column.displayColumn ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
-- 선택 안함 --
|
||||||
|
</CommandItem>
|
||||||
|
{referenceTableColumns[column.referenceTable]?.map((refCol) => (
|
||||||
|
<CommandItem
|
||||||
|
key={refCol.columnName}
|
||||||
|
value={`${refCol.columnLabel || ""} ${refCol.columnName}`}
|
||||||
|
onSelect={() => {
|
||||||
|
handleDetailSettingsChange(column.columnName, "entity_display_column", refCol.columnName);
|
||||||
|
setEntityComboboxOpen((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[column.columnName]: { ...prev[column.columnName], displayColumn: false },
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-3 w-3",
|
||||||
|
column.displayColumn === refCol.columnName ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{refCol.columnName}</span>
|
||||||
|
{refCol.columnLabel && (
|
||||||
|
<span className="text-muted-foreground text-[10px]">{refCol.columnLabel}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -1505,8 +1668,8 @@ export default function TableManagementPage() {
|
||||||
column.referenceColumn !== "none" &&
|
column.referenceColumn !== "none" &&
|
||||||
column.displayColumn &&
|
column.displayColumn &&
|
||||||
column.displayColumn !== "none" && (
|
column.displayColumn !== "none" && (
|
||||||
<div className="bg-primary/10 text-primary flex w-48 items-center gap-1 rounded px-2 py-1 text-xs">
|
<div className="bg-primary/10 text-primary flex items-center gap-1 rounded px-2 py-1 text-xs">
|
||||||
<span>✓</span>
|
<Check className="h-3 w-3" />
|
||||||
<span className="truncate">설정 완료</span>
|
<span className="truncate">설정 완료</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
import "@/app/globals.css";
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "POP - 생산실적관리",
|
||||||
|
description: "생산 현장 실적 관리 시스템",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PopLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { PopDashboard } from "@/components/pop/dashboard";
|
||||||
|
|
||||||
|
export default function PopPage() {
|
||||||
|
return <PopDashboard />;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { PopApp } from "@/components/pop";
|
||||||
|
|
||||||
|
export default function PopWorkPage() {
|
||||||
|
return <PopApp />;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { PopApp } from "@/components/pop";
|
||||||
|
|
||||||
|
export default function PopWorkPage() {
|
||||||
|
return <PopApp />;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -388,4 +388,183 @@ select {
|
||||||
border-spacing: 0 !important;
|
border-spacing: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== POP (Production Operation Panel) Styles ===== */
|
||||||
|
|
||||||
|
/* POP 전용 다크 테마 변수 */
|
||||||
|
.pop-dark {
|
||||||
|
/* 배경 색상 */
|
||||||
|
--pop-bg-deepest: 8 12 21;
|
||||||
|
--pop-bg-deep: 10 15 28;
|
||||||
|
--pop-bg-primary: 13 19 35;
|
||||||
|
--pop-bg-secondary: 18 26 47;
|
||||||
|
--pop-bg-tertiary: 25 35 60;
|
||||||
|
--pop-bg-elevated: 32 45 75;
|
||||||
|
|
||||||
|
/* 네온 강조색 */
|
||||||
|
--pop-neon-cyan: 0 212 255;
|
||||||
|
--pop-neon-cyan-bright: 0 240 255;
|
||||||
|
--pop-neon-cyan-dim: 0 150 190;
|
||||||
|
--pop-neon-pink: 255 0 102;
|
||||||
|
--pop-neon-purple: 138 43 226;
|
||||||
|
|
||||||
|
/* 상태 색상 */
|
||||||
|
--pop-success: 0 255 136;
|
||||||
|
--pop-success-dim: 0 180 100;
|
||||||
|
--pop-warning: 255 170 0;
|
||||||
|
--pop-warning-dim: 200 130 0;
|
||||||
|
--pop-danger: 255 51 51;
|
||||||
|
--pop-danger-dim: 200 40 40;
|
||||||
|
|
||||||
|
/* 텍스트 색상 */
|
||||||
|
--pop-text-primary: 255 255 255;
|
||||||
|
--pop-text-secondary: 180 195 220;
|
||||||
|
--pop-text-muted: 100 120 150;
|
||||||
|
|
||||||
|
/* 테두리 색상 */
|
||||||
|
--pop-border: 40 55 85;
|
||||||
|
--pop-border-light: 55 75 110;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* POP 전용 라이트 테마 변수 */
|
||||||
|
.pop-light {
|
||||||
|
--pop-bg-deepest: 245 247 250;
|
||||||
|
--pop-bg-deep: 240 243 248;
|
||||||
|
--pop-bg-primary: 250 251 253;
|
||||||
|
--pop-bg-secondary: 255 255 255;
|
||||||
|
--pop-bg-tertiary: 245 247 250;
|
||||||
|
--pop-bg-elevated: 235 238 245;
|
||||||
|
|
||||||
|
--pop-neon-cyan: 0 122 204;
|
||||||
|
--pop-neon-cyan-bright: 0 140 230;
|
||||||
|
--pop-neon-cyan-dim: 0 100 170;
|
||||||
|
--pop-neon-pink: 220 38 127;
|
||||||
|
--pop-neon-purple: 118 38 200;
|
||||||
|
|
||||||
|
--pop-success: 22 163 74;
|
||||||
|
--pop-success-dim: 21 128 61;
|
||||||
|
--pop-warning: 245 158 11;
|
||||||
|
--pop-warning-dim: 217 119 6;
|
||||||
|
--pop-danger: 220 38 38;
|
||||||
|
--pop-danger-dim: 185 28 28;
|
||||||
|
|
||||||
|
--pop-text-primary: 15 23 42;
|
||||||
|
--pop-text-secondary: 71 85 105;
|
||||||
|
--pop-text-muted: 148 163 184;
|
||||||
|
|
||||||
|
--pop-border: 226 232 240;
|
||||||
|
--pop-border-light: 203 213 225;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* POP 배경 그리드 패턴 */
|
||||||
|
.pop-bg-pattern::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: repeating-linear-gradient(90deg, rgba(0, 212, 255, 0.03) 0px, transparent 1px, transparent 60px),
|
||||||
|
repeating-linear-gradient(0deg, rgba(0, 212, 255, 0.03) 0px, transparent 1px, transparent 60px),
|
||||||
|
radial-gradient(ellipse 80% 50% at 50% 0%, rgba(0, 212, 255, 0.08) 0%, transparent 60%);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-light .pop-bg-pattern::before {
|
||||||
|
background: repeating-linear-gradient(90deg, rgba(0, 122, 204, 0.02) 0px, transparent 1px, transparent 60px),
|
||||||
|
repeating-linear-gradient(0deg, rgba(0, 122, 204, 0.02) 0px, transparent 1px, transparent 60px),
|
||||||
|
radial-gradient(ellipse 80% 50% at 50% 0%, rgba(0, 122, 204, 0.05) 0%, transparent 60%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* POP 글로우 효과 */
|
||||||
|
.pop-glow-cyan {
|
||||||
|
box-shadow: 0 0 20px rgba(0, 212, 255, 0.5), 0 0 40px rgba(0, 212, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-glow-cyan-strong {
|
||||||
|
box-shadow: 0 0 10px rgba(0, 212, 255, 0.8), 0 0 30px rgba(0, 212, 255, 0.5), 0 0 50px rgba(0, 212, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-glow-success {
|
||||||
|
box-shadow: 0 0 15px rgba(0, 255, 136, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-glow-warning {
|
||||||
|
box-shadow: 0 0 15px rgba(255, 170, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-glow-danger {
|
||||||
|
box-shadow: 0 0 15px rgba(255, 51, 51, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* POP 펄스 글로우 애니메이션 */
|
||||||
|
@keyframes pop-pulse-glow {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 5px rgba(0, 212, 255, 0.5);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 20px rgba(0, 212, 255, 0.8), 0 0 30px rgba(0, 212, 255, 0.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-animate-pulse-glow {
|
||||||
|
animation: pop-pulse-glow 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* POP 프로그레스 바 샤인 애니메이션 */
|
||||||
|
@keyframes pop-progress-shine {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-20px);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-progress-shine::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 20px;
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3));
|
||||||
|
animation: pop-progress-shine 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* POP 스크롤바 스타일 */
|
||||||
|
.pop-scrollbar::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-scrollbar::-webkit-scrollbar-track {
|
||||||
|
background: rgb(var(--pop-bg-secondary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-scrollbar::-webkit-scrollbar-thumb {
|
||||||
|
background: rgb(var(--pop-border-light));
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgb(var(--pop-neon-cyan-dim));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* POP 스크롤바 숨기기 */
|
||||||
|
.pop-hide-scrollbar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-hide-scrollbar {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== End of Global Styles ===== */
|
/* ===== End of Global Styles ===== */
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,13 @@ const OPERATOR_LABELS: Record<string, string> = {
|
||||||
NOT_IN: "NOT IN",
|
NOT_IN: "NOT IN",
|
||||||
IS_NULL: "NULL",
|
IS_NULL: "NULL",
|
||||||
IS_NOT_NULL: "NOT NULL",
|
IS_NOT_NULL: "NOT NULL",
|
||||||
|
EXISTS_IN: "EXISTS IN",
|
||||||
|
NOT_EXISTS_IN: "NOT EXISTS IN",
|
||||||
|
};
|
||||||
|
|
||||||
|
// EXISTS 계열 연산자인지 확인
|
||||||
|
const isExistsOperator = (operator: string): boolean => {
|
||||||
|
return operator === "EXISTS_IN" || operator === "NOT_EXISTS_IN";
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ConditionNode = memo(({ data, selected }: NodeProps<ConditionNodeData>) => {
|
export const ConditionNode = memo(({ data, selected }: NodeProps<ConditionNodeData>) => {
|
||||||
|
|
@ -54,15 +61,31 @@ export const ConditionNode = memo(({ data, selected }: NodeProps<ConditionNodeDa
|
||||||
{idx > 0 && (
|
{idx > 0 && (
|
||||||
<div className="mb-1 text-center text-xs font-semibold text-yellow-600">{data.logic}</div>
|
<div className="mb-1 text-center text-xs font-semibold text-yellow-600">{data.logic}</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
<span className="font-mono text-gray-700">{condition.field}</span>
|
<span className="font-mono text-gray-700">{condition.field}</span>
|
||||||
<span className="rounded bg-yellow-200 px-1 py-0.5 text-yellow-800">
|
<span
|
||||||
|
className={`rounded px-1 py-0.5 ${
|
||||||
|
isExistsOperator(condition.operator)
|
||||||
|
? "bg-purple-200 text-purple-800"
|
||||||
|
: "bg-yellow-200 text-yellow-800"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{OPERATOR_LABELS[condition.operator] || condition.operator}
|
{OPERATOR_LABELS[condition.operator] || condition.operator}
|
||||||
</span>
|
</span>
|
||||||
{condition.value !== null && condition.value !== undefined && (
|
{/* EXISTS 연산자인 경우 테이블.필드 표시 */}
|
||||||
<span className="text-gray-600">
|
{isExistsOperator(condition.operator) ? (
|
||||||
{typeof condition.value === "string" ? `"${condition.value}"` : String(condition.value)}
|
<span className="text-purple-600">
|
||||||
|
{(condition as any).lookupTableLabel || (condition as any).lookupTable || "..."}
|
||||||
|
{(condition as any).lookupField && `.${(condition as any).lookupFieldLabel || (condition as any).lookupField}`}
|
||||||
</span>
|
</span>
|
||||||
|
) : (
|
||||||
|
// 일반 연산자인 경우 값 표시
|
||||||
|
condition.value !== null &&
|
||||||
|
condition.value !== undefined && (
|
||||||
|
<span className="text-gray-600">
|
||||||
|
{typeof condition.value === "string" ? `"${condition.value}"` : String(condition.value)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,18 @@
|
||||||
* 조건 분기 노드 속성 편집
|
* 조건 분기 노드 속성 편집
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import { Plus, Trash2 } from "lucide-react";
|
import { Plus, Trash2, Database, Search, Check, ChevronsUpDown } from "lucide-react";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||||
import type { ConditionNodeData } from "@/types/node-editor";
|
import type { ConditionNodeData, ConditionOperator } from "@/types/node-editor";
|
||||||
|
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
// 필드 정의
|
// 필드 정의
|
||||||
interface FieldDefinition {
|
interface FieldDefinition {
|
||||||
|
|
@ -20,6 +24,19 @@ interface FieldDefinition {
|
||||||
type?: string;
|
type?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 테이블 정보
|
||||||
|
interface TableInfo {
|
||||||
|
tableName: string;
|
||||||
|
tableLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 테이블 컬럼 정보
|
||||||
|
interface ColumnInfo {
|
||||||
|
columnName: string;
|
||||||
|
columnLabel: string;
|
||||||
|
dataType: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface ConditionPropertiesProps {
|
interface ConditionPropertiesProps {
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
data: ConditionNodeData;
|
data: ConditionNodeData;
|
||||||
|
|
@ -38,8 +55,194 @@ const OPERATORS = [
|
||||||
{ value: "NOT_IN", label: "NOT IN" },
|
{ value: "NOT_IN", label: "NOT IN" },
|
||||||
{ value: "IS_NULL", label: "NULL" },
|
{ value: "IS_NULL", label: "NULL" },
|
||||||
{ value: "IS_NOT_NULL", label: "NOT NULL" },
|
{ value: "IS_NOT_NULL", label: "NOT NULL" },
|
||||||
|
{ value: "EXISTS_IN", label: "다른 테이블에 존재함" },
|
||||||
|
{ value: "NOT_EXISTS_IN", label: "다른 테이블에 존재하지 않음" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
// EXISTS 계열 연산자인지 확인
|
||||||
|
const isExistsOperator = (operator: string): boolean => {
|
||||||
|
return operator === "EXISTS_IN" || operator === "NOT_EXISTS_IN";
|
||||||
|
};
|
||||||
|
|
||||||
|
// 테이블 선택용 검색 가능한 Combobox
|
||||||
|
function TableCombobox({
|
||||||
|
tables,
|
||||||
|
value,
|
||||||
|
onSelect,
|
||||||
|
placeholder = "테이블 검색...",
|
||||||
|
}: {
|
||||||
|
tables: TableInfo[];
|
||||||
|
value: string;
|
||||||
|
onSelect: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const selectedTable = tables.find((t) => t.tableName === value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
className="mt-1 h-8 w-full justify-between text-xs"
|
||||||
|
>
|
||||||
|
{selectedTable ? (
|
||||||
|
<span className="truncate">
|
||||||
|
{selectedTable.tableLabel}
|
||||||
|
<span className="ml-1 text-gray-400">({selectedTable.tableName})</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">테이블 선택</span>
|
||||||
|
)}
|
||||||
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[300px] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder={placeholder} className="h-8 text-xs" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="py-2 text-center text-xs">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||||
|
<CommandGroup className="max-h-[200px] overflow-y-auto">
|
||||||
|
{tables.map((table) => (
|
||||||
|
<CommandItem
|
||||||
|
key={table.tableName}
|
||||||
|
value={`${table.tableLabel} ${table.tableName}`}
|
||||||
|
onSelect={() => {
|
||||||
|
onSelect(table.tableName);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check className={cn("mr-2 h-3 w-3", value === table.tableName ? "opacity-100" : "opacity-0")} />
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{table.tableLabel}</span>
|
||||||
|
<span className="text-[10px] text-gray-500">{table.tableName}</span>
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 컬럼 선택용 검색 가능한 Combobox
|
||||||
|
function ColumnCombobox({
|
||||||
|
columns,
|
||||||
|
value,
|
||||||
|
onSelect,
|
||||||
|
placeholder = "컬럼 검색...",
|
||||||
|
}: {
|
||||||
|
columns: ColumnInfo[];
|
||||||
|
value: string;
|
||||||
|
onSelect: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const selectedColumn = columns.find((c) => c.columnName === value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
className="mt-1 h-8 w-full justify-between text-xs"
|
||||||
|
>
|
||||||
|
{selectedColumn ? (
|
||||||
|
<span className="truncate">
|
||||||
|
{selectedColumn.columnLabel}
|
||||||
|
<span className="ml-1 text-gray-400">({selectedColumn.columnName})</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">컬럼 선택</span>
|
||||||
|
)}
|
||||||
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[300px] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder={placeholder} className="h-8 text-xs" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="py-2 text-center text-xs">컬럼을 찾을 수 없습니다.</CommandEmpty>
|
||||||
|
<CommandGroup className="max-h-[200px] overflow-y-auto">
|
||||||
|
{columns.map((col) => (
|
||||||
|
<CommandItem
|
||||||
|
key={col.columnName}
|
||||||
|
value={`${col.columnLabel} ${col.columnName}`}
|
||||||
|
onSelect={() => {
|
||||||
|
onSelect(col.columnName);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check className={cn("mr-2 h-3 w-3", value === col.columnName ? "opacity-100" : "opacity-0")} />
|
||||||
|
<span className="font-medium">{col.columnLabel}</span>
|
||||||
|
<span className="ml-1 text-[10px] text-gray-400">({col.columnName})</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 컬럼 선택 섹션 (자동 로드 포함)
|
||||||
|
function ColumnSelectSection({
|
||||||
|
lookupTable,
|
||||||
|
lookupField,
|
||||||
|
tableColumnsCache,
|
||||||
|
loadingColumns,
|
||||||
|
loadTableColumns,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
lookupTable: string;
|
||||||
|
lookupField: string;
|
||||||
|
tableColumnsCache: Record<string, ColumnInfo[]>;
|
||||||
|
loadingColumns: Record<string, boolean>;
|
||||||
|
loadTableColumns: (tableName: string) => Promise<ColumnInfo[]>;
|
||||||
|
onSelect: (value: string) => void;
|
||||||
|
}) {
|
||||||
|
// 캐시에 없고 로딩 중이 아니면 자동으로 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (lookupTable && !tableColumnsCache[lookupTable] && !loadingColumns[lookupTable]) {
|
||||||
|
loadTableColumns(lookupTable);
|
||||||
|
}
|
||||||
|
}, [lookupTable, tableColumnsCache, loadingColumns, loadTableColumns]);
|
||||||
|
|
||||||
|
const isLoading = loadingColumns[lookupTable];
|
||||||
|
const columns = tableColumnsCache[lookupTable];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-gray-600">
|
||||||
|
<Search className="mr-1 inline h-3 w-3" />
|
||||||
|
비교할 컬럼
|
||||||
|
</Label>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="mt-1 rounded border border-dashed bg-gray-50 p-2 text-center text-xs text-gray-400">
|
||||||
|
컬럼 목록 로딩 중...
|
||||||
|
</div>
|
||||||
|
) : columns && columns.length > 0 ? (
|
||||||
|
<ColumnCombobox columns={columns} value={lookupField} onSelect={onSelect} placeholder="컬럼 검색..." />
|
||||||
|
) : (
|
||||||
|
<div className="mt-1 rounded border border-dashed bg-gray-50 p-2 text-center text-xs text-gray-400">
|
||||||
|
컬럼 목록을 로드할 수 없습니다
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps) {
|
export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps) {
|
||||||
const { updateNode, nodes, edges } = useFlowEditorStore();
|
const { updateNode, nodes, edges } = useFlowEditorStore();
|
||||||
|
|
||||||
|
|
@ -48,6 +251,12 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
|
||||||
const [logic, setLogic] = useState<"AND" | "OR">(data.logic || "AND");
|
const [logic, setLogic] = useState<"AND" | "OR">(data.logic || "AND");
|
||||||
const [availableFields, setAvailableFields] = useState<FieldDefinition[]>([]);
|
const [availableFields, setAvailableFields] = useState<FieldDefinition[]>([]);
|
||||||
|
|
||||||
|
// EXISTS 연산자용 상태
|
||||||
|
const [allTables, setAllTables] = useState<TableInfo[]>([]);
|
||||||
|
const [tableColumnsCache, setTableColumnsCache] = useState<Record<string, ColumnInfo[]>>({});
|
||||||
|
const [loadingTables, setLoadingTables] = useState(false);
|
||||||
|
const [loadingColumns, setLoadingColumns] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
// 데이터 변경 시 로컬 상태 업데이트
|
// 데이터 변경 시 로컬 상태 업데이트
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDisplayName(data.displayName || "조건 분기");
|
setDisplayName(data.displayName || "조건 분기");
|
||||||
|
|
@ -55,6 +264,100 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
|
||||||
setLogic(data.logic || "AND");
|
setLogic(data.logic || "AND");
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
|
// 전체 테이블 목록 로드 (EXISTS 연산자용)
|
||||||
|
useEffect(() => {
|
||||||
|
const loadAllTables = async () => {
|
||||||
|
// 이미 EXISTS 연산자가 있거나 로드된 적이 있으면 스킵
|
||||||
|
if (allTables.length > 0) return;
|
||||||
|
|
||||||
|
// EXISTS 연산자가 하나라도 있으면 테이블 목록 로드
|
||||||
|
const hasExistsOperator = conditions.some((c) => isExistsOperator(c.operator));
|
||||||
|
if (!hasExistsOperator) return;
|
||||||
|
|
||||||
|
setLoadingTables(true);
|
||||||
|
try {
|
||||||
|
const response = await tableManagementApi.getTableList();
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setAllTables(
|
||||||
|
response.data.map((t: any) => ({
|
||||||
|
tableName: t.tableName,
|
||||||
|
tableLabel: t.tableLabel || t.tableName,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("테이블 목록 로드 실패:", error);
|
||||||
|
} finally {
|
||||||
|
setLoadingTables(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadAllTables();
|
||||||
|
}, [conditions, allTables.length]);
|
||||||
|
|
||||||
|
// 테이블 컬럼 로드 함수
|
||||||
|
const loadTableColumns = useCallback(
|
||||||
|
async (tableName: string): Promise<ColumnInfo[]> => {
|
||||||
|
// 캐시에 있으면 반환
|
||||||
|
if (tableColumnsCache[tableName]) {
|
||||||
|
return tableColumnsCache[tableName];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이미 로딩 중이면 스킵
|
||||||
|
if (loadingColumns[tableName]) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 로딩 상태 설정
|
||||||
|
setLoadingColumns((prev) => ({ ...prev, [tableName]: true }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// getColumnList 반환: { success, data: { columns, total, ... } }
|
||||||
|
const response = await tableManagementApi.getColumnList(tableName);
|
||||||
|
if (response.success && response.data && response.data.columns) {
|
||||||
|
const columns = response.data.columns.map((c: any) => ({
|
||||||
|
columnName: c.columnName,
|
||||||
|
columnLabel: c.columnLabel || c.columnName,
|
||||||
|
dataType: c.dataType,
|
||||||
|
}));
|
||||||
|
setTableColumnsCache((prev) => ({ ...prev, [tableName]: columns }));
|
||||||
|
console.log(`✅ 테이블 ${tableName} 컬럼 로드 완료:`, columns.length, "개");
|
||||||
|
return columns;
|
||||||
|
} else {
|
||||||
|
console.warn(`⚠️ 테이블 ${tableName} 컬럼 조회 실패:`, response);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ 테이블 ${tableName} 컬럼 로드 실패:`, error);
|
||||||
|
} finally {
|
||||||
|
setLoadingColumns((prev) => ({ ...prev, [tableName]: false }));
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
[tableColumnsCache, loadingColumns]
|
||||||
|
);
|
||||||
|
|
||||||
|
// EXISTS 연산자 선택 시 테이블 목록 강제 로드
|
||||||
|
const ensureTablesLoaded = useCallback(async () => {
|
||||||
|
if (allTables.length > 0) return;
|
||||||
|
|
||||||
|
setLoadingTables(true);
|
||||||
|
try {
|
||||||
|
const response = await tableManagementApi.getTableList();
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setAllTables(
|
||||||
|
response.data.map((t: any) => ({
|
||||||
|
tableName: t.tableName,
|
||||||
|
tableLabel: t.tableLabel || t.tableName,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("테이블 목록 로드 실패:", error);
|
||||||
|
} finally {
|
||||||
|
setLoadingTables(false);
|
||||||
|
}
|
||||||
|
}, [allTables.length]);
|
||||||
|
|
||||||
// 🔥 연결된 소스 노드의 필드를 재귀적으로 수집
|
// 🔥 연결된 소스 노드의 필드를 재귀적으로 수집
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const getAllSourceFields = (currentNodeId: string, visited: Set<string> = new Set()): FieldDefinition[] => {
|
const getAllSourceFields = (currentNodeId: string, visited: Set<string> = new Set()): FieldDefinition[] => {
|
||||||
|
|
@ -170,15 +473,18 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
|
||||||
}, [nodeId, nodes, edges]);
|
}, [nodeId, nodes, edges]);
|
||||||
|
|
||||||
const handleAddCondition = () => {
|
const handleAddCondition = () => {
|
||||||
setConditions([
|
const newCondition = {
|
||||||
...conditions,
|
field: "",
|
||||||
{
|
operator: "EQUALS" as ConditionOperator,
|
||||||
field: "",
|
value: "",
|
||||||
operator: "EQUALS",
|
valueType: "static" as "static" | "field",
|
||||||
value: "",
|
// EXISTS 연산자용 필드는 초기값 없음
|
||||||
valueType: "static", // "static" (고정값) 또는 "field" (필드 참조)
|
lookupTable: undefined,
|
||||||
},
|
lookupTableLabel: undefined,
|
||||||
]);
|
lookupField: undefined,
|
||||||
|
lookupFieldLabel: undefined,
|
||||||
|
};
|
||||||
|
setConditions([...conditions, newCondition]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveCondition = (index: number) => {
|
const handleRemoveCondition = (index: number) => {
|
||||||
|
|
@ -196,9 +502,50 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleConditionChange = (index: number, field: string, value: any) => {
|
const handleConditionChange = async (index: number, field: string, value: any) => {
|
||||||
const newConditions = [...conditions];
|
const newConditions = [...conditions];
|
||||||
newConditions[index] = { ...newConditions[index], [field]: value };
|
newConditions[index] = { ...newConditions[index], [field]: value };
|
||||||
|
|
||||||
|
// EXISTS 연산자로 변경 시 테이블 목록 로드 및 기존 value/valueType 초기화
|
||||||
|
if (field === "operator" && isExistsOperator(value)) {
|
||||||
|
await ensureTablesLoaded();
|
||||||
|
// EXISTS 연산자에서는 value, valueType이 필요 없으므로 초기화
|
||||||
|
newConditions[index].value = "";
|
||||||
|
newConditions[index].valueType = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// EXISTS 연산자에서 다른 연산자로 변경 시 lookup 필드들 초기화
|
||||||
|
if (field === "operator" && !isExistsOperator(value)) {
|
||||||
|
newConditions[index].lookupTable = undefined;
|
||||||
|
newConditions[index].lookupTableLabel = undefined;
|
||||||
|
newConditions[index].lookupField = undefined;
|
||||||
|
newConditions[index].lookupFieldLabel = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookupTable 변경 시 컬럼 목록 로드 및 라벨 설정
|
||||||
|
if (field === "lookupTable" && value) {
|
||||||
|
const tableInfo = allTables.find((t) => t.tableName === value);
|
||||||
|
if (tableInfo) {
|
||||||
|
newConditions[index].lookupTableLabel = tableInfo.tableLabel;
|
||||||
|
}
|
||||||
|
// 테이블 변경 시 필드 초기화
|
||||||
|
newConditions[index].lookupField = undefined;
|
||||||
|
newConditions[index].lookupFieldLabel = undefined;
|
||||||
|
// 컬럼 목록 미리 로드
|
||||||
|
await loadTableColumns(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookupField 변경 시 라벨 설정
|
||||||
|
if (field === "lookupField" && value) {
|
||||||
|
const tableName = newConditions[index].lookupTable;
|
||||||
|
if (tableName && tableColumnsCache[tableName]) {
|
||||||
|
const columnInfo = tableColumnsCache[tableName].find((c) => c.columnName === value);
|
||||||
|
if (columnInfo) {
|
||||||
|
newConditions[index].lookupFieldLabel = columnInfo.columnLabel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setConditions(newConditions);
|
setConditions(newConditions);
|
||||||
updateNode(nodeId, {
|
updateNode(nodeId, {
|
||||||
conditions: newConditions,
|
conditions: newConditions,
|
||||||
|
|
@ -329,64 +676,114 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{condition.operator !== "IS_NULL" && condition.operator !== "IS_NOT_NULL" && (
|
{/* EXISTS 연산자인 경우: 테이블/필드 선택 UI (검색 가능한 Combobox) */}
|
||||||
|
{isExistsOperator(condition.operator) && (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs text-gray-600">비교 값 타입</Label>
|
<Label className="text-xs text-gray-600">
|
||||||
<Select
|
<Database className="mr-1 inline h-3 w-3" />
|
||||||
value={(condition as any).valueType || "static"}
|
조회할 테이블
|
||||||
onValueChange={(value) => handleConditionChange(index, "valueType", value)}
|
</Label>
|
||||||
>
|
{loadingTables ? (
|
||||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
<div className="mt-1 rounded border border-dashed bg-gray-50 p-2 text-center text-xs text-gray-400">
|
||||||
<SelectValue />
|
테이블 목록 로딩 중...
|
||||||
</SelectTrigger>
|
</div>
|
||||||
<SelectContent>
|
) : allTables.length > 0 ? (
|
||||||
<SelectItem value="static">고정값</SelectItem>
|
<TableCombobox
|
||||||
<SelectItem value="field">필드 참조</SelectItem>
|
tables={allTables}
|
||||||
</SelectContent>
|
value={(condition as any).lookupTable || ""}
|
||||||
</Select>
|
onSelect={(value) => handleConditionChange(index, "lookupTable", value)}
|
||||||
|
placeholder="테이블 검색..."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="mt-1 rounded border border-dashed bg-gray-50 p-2 text-center text-xs text-gray-400">
|
||||||
|
테이블 목록을 로드할 수 없습니다
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{(condition as any).lookupTable && (
|
||||||
<Label className="text-xs text-gray-600">
|
<ColumnSelectSection
|
||||||
{(condition as any).valueType === "field" ? "비교 필드" : "비교 값"}
|
lookupTable={(condition as any).lookupTable}
|
||||||
</Label>
|
lookupField={(condition as any).lookupField || ""}
|
||||||
{(condition as any).valueType === "field" ? (
|
tableColumnsCache={tableColumnsCache}
|
||||||
// 필드 참조: 드롭다운으로 선택
|
loadingColumns={loadingColumns}
|
||||||
availableFields.length > 0 ? (
|
loadTableColumns={loadTableColumns}
|
||||||
<Select
|
onSelect={(value) => handleConditionChange(index, "lookupField", value)}
|
||||||
value={condition.value as string}
|
/>
|
||||||
onValueChange={(value) => handleConditionChange(index, "value", value)}
|
)}
|
||||||
>
|
|
||||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
<div className="rounded bg-purple-50 p-2 text-xs text-purple-700">
|
||||||
<SelectValue placeholder="비교할 필드 선택" />
|
{condition.operator === "EXISTS_IN"
|
||||||
</SelectTrigger>
|
? `소스의 "${condition.field || "..."}" 값이 "${(condition as any).lookupTableLabel || "..."}" 테이블의 "${(condition as any).lookupFieldLabel || "..."}" 컬럼에 존재하면 TRUE`
|
||||||
<SelectContent>
|
: `소스의 "${condition.field || "..."}" 값이 "${(condition as any).lookupTableLabel || "..."}" 테이블의 "${(condition as any).lookupFieldLabel || "..."}" 컬럼에 존재하지 않으면 TRUE`}
|
||||||
{availableFields.map((field) => (
|
|
||||||
<SelectItem key={field.name} value={field.name}>
|
|
||||||
{field.label || field.name}
|
|
||||||
{field.type && <span className="ml-2 text-xs text-gray-400">({field.type})</span>}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
) : (
|
|
||||||
<div className="mt-1 rounded border border-dashed bg-gray-50 p-2 text-center text-xs text-gray-400">
|
|
||||||
소스 노드를 연결하세요
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
// 고정값: 직접 입력
|
|
||||||
<Input
|
|
||||||
value={condition.value as string}
|
|
||||||
onChange={(e) => handleConditionChange(index, "value", e.target.value)}
|
|
||||||
placeholder="비교할 값"
|
|
||||||
className="mt-1 h-8 text-xs"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 일반 연산자인 경우: 기존 비교값 UI */}
|
||||||
|
{condition.operator !== "IS_NULL" &&
|
||||||
|
condition.operator !== "IS_NOT_NULL" &&
|
||||||
|
!isExistsOperator(condition.operator) && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-gray-600">비교 값 타입</Label>
|
||||||
|
<Select
|
||||||
|
value={(condition as any).valueType || "static"}
|
||||||
|
onValueChange={(value) => handleConditionChange(index, "valueType", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="static">고정값</SelectItem>
|
||||||
|
<SelectItem value="field">필드 참조</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-gray-600">
|
||||||
|
{(condition as any).valueType === "field" ? "비교 필드" : "비교 값"}
|
||||||
|
</Label>
|
||||||
|
{(condition as any).valueType === "field" ? (
|
||||||
|
// 필드 참조: 드롭다운으로 선택
|
||||||
|
availableFields.length > 0 ? (
|
||||||
|
<Select
|
||||||
|
value={condition.value as string}
|
||||||
|
onValueChange={(value) => handleConditionChange(index, "value", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||||
|
<SelectValue placeholder="비교할 필드 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{availableFields.map((field) => (
|
||||||
|
<SelectItem key={field.name} value={field.name}>
|
||||||
|
{field.label || field.name}
|
||||||
|
{field.type && (
|
||||||
|
<span className="ml-2 text-xs text-gray-400">({field.type})</span>
|
||||||
|
)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<div className="mt-1 rounded border border-dashed bg-gray-50 p-2 text-center text-xs text-gray-400">
|
||||||
|
소스 노드를 연결하세요
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
// 고정값: 직접 입력
|
||||||
|
<Input
|
||||||
|
value={condition.value as string}
|
||||||
|
onChange={(e) => handleConditionChange(index, "value", e.target.value)}
|
||||||
|
placeholder="비교할 값"
|
||||||
|
className="mt-1 h-8 text-xs"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -402,20 +799,28 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
|
||||||
{/* 안내 */}
|
{/* 안내 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="rounded bg-blue-50 p-3 text-xs text-blue-700">
|
<div className="rounded bg-blue-50 p-3 text-xs text-blue-700">
|
||||||
🔌 <strong>소스 노드 연결</strong>: 테이블/외부DB 노드를 연결하면 자동으로 필드 목록이 표시됩니다.
|
<strong>소스 노드 연결</strong>: 테이블/외부DB 노드를 연결하면 자동으로 필드 목록이 표시됩니다.
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded bg-green-50 p-3 text-xs text-green-700">
|
<div className="rounded bg-green-50 p-3 text-xs text-green-700">
|
||||||
🔄 <strong>비교 값 타입</strong>:<br />• <strong>고정값</strong>: 직접 입력한 값과 비교 (예: age > 30)
|
<strong>비교 값 타입</strong>:<br />
|
||||||
<br />• <strong>필드 참조</strong>: 다른 필드의 값과 비교 (예: 주문수량 > 재고수량)
|
- <strong>고정값</strong>: 직접 입력한 값과 비교 (예: age > 30)
|
||||||
|
<br />- <strong>필드 참조</strong>: 다른 필드의 값과 비교 (예: 주문수량 > 재고수량)
|
||||||
|
</div>
|
||||||
|
<div className="rounded bg-purple-50 p-3 text-xs text-purple-700">
|
||||||
|
<strong>테이블 존재 여부 검사</strong>:<br />
|
||||||
|
- <strong>다른 테이블에 존재함</strong>: 값이 다른 테이블에 있으면 TRUE
|
||||||
|
<br />- <strong>다른 테이블에 존재하지 않음</strong>: 값이 다른 테이블에 없으면 TRUE
|
||||||
|
<br />
|
||||||
|
(예: 품명이 품목정보 테이블에 없으면 자동 등록)
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded bg-yellow-50 p-3 text-xs text-yellow-700">
|
<div className="rounded bg-yellow-50 p-3 text-xs text-yellow-700">
|
||||||
💡 <strong>AND</strong>: 모든 조건이 참이어야 TRUE 출력
|
<strong>AND</strong>: 모든 조건이 참이어야 TRUE 출력
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded bg-yellow-50 p-3 text-xs text-yellow-700">
|
<div className="rounded bg-yellow-50 p-3 text-xs text-yellow-700">
|
||||||
💡 <strong>OR</strong>: 하나라도 참이면 TRUE 출력
|
<strong>OR</strong>: 하나라도 참이면 TRUE 출력
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded bg-yellow-50 p-3 text-xs text-yellow-700">
|
<div className="rounded bg-yellow-50 p-3 text-xs text-yellow-700">
|
||||||
⚡ TRUE 출력은 오른쪽 위, FALSE 출력은 오른쪽 아래입니다.
|
TRUE 출력은 오른쪽 위, FALSE 출력은 오른쪽 아래입니다.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,131 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { X, Info } from "lucide-react";
|
||||||
|
import { WorkOrder } from "./types";
|
||||||
|
|
||||||
|
interface PopAcceptModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
workOrder: WorkOrder | null;
|
||||||
|
quantity: number;
|
||||||
|
onQuantityChange: (qty: number) => void;
|
||||||
|
onConfirm: (quantity: number) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PopAcceptModal({
|
||||||
|
isOpen,
|
||||||
|
workOrder,
|
||||||
|
quantity,
|
||||||
|
onQuantityChange,
|
||||||
|
onConfirm,
|
||||||
|
onClose,
|
||||||
|
}: PopAcceptModalProps) {
|
||||||
|
if (!isOpen || !workOrder) return null;
|
||||||
|
|
||||||
|
const acceptedQty = workOrder.acceptedQuantity || 0;
|
||||||
|
const remainingQty = workOrder.orderQuantity - acceptedQty;
|
||||||
|
|
||||||
|
const handleAdjust = (delta: number) => {
|
||||||
|
const newQty = Math.max(1, Math.min(quantity + delta, remainingQty));
|
||||||
|
onQuantityChange(newQty);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const val = parseInt(e.target.value) || 0;
|
||||||
|
const newQty = Math.max(0, Math.min(val, remainingQty));
|
||||||
|
onQuantityChange(newQty);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (quantity > 0) {
|
||||||
|
onConfirm(quantity);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pop-modal-overlay active" onClick={(e) => e.target === e.currentTarget && onClose()}>
|
||||||
|
<div className="pop-modal">
|
||||||
|
<div className="pop-modal-header">
|
||||||
|
<h2 className="pop-modal-title">작업 접수</h2>
|
||||||
|
<button className="pop-modal-close" onClick={onClose}>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pop-modal-body">
|
||||||
|
<div className="pop-accept-modal-content">
|
||||||
|
{/* 작업지시 정보 */}
|
||||||
|
<div className="pop-accept-work-info">
|
||||||
|
<div className="work-id">{workOrder.id}</div>
|
||||||
|
<div className="work-name">
|
||||||
|
{workOrder.itemName} ({workOrder.spec})
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: "var(--spacing-sm)", fontSize: "var(--text-xs)", color: "rgb(var(--text-muted))" }}>
|
||||||
|
지시수량: {workOrder.orderQuantity} EA | 기 접수: {acceptedQty} EA
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 수량 입력 */}
|
||||||
|
<div>
|
||||||
|
<label className="pop-form-label">접수 수량</label>
|
||||||
|
<div className="pop-quantity-input-wrapper">
|
||||||
|
<button className="pop-qty-btn minus" onClick={() => handleAdjust(-10)}>
|
||||||
|
-10
|
||||||
|
</button>
|
||||||
|
<button className="pop-qty-btn minus" onClick={() => handleAdjust(-1)}>
|
||||||
|
-1
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="pop-qty-input"
|
||||||
|
value={quantity}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
min={1}
|
||||||
|
max={remainingQty}
|
||||||
|
/>
|
||||||
|
<button className="pop-qty-btn" onClick={() => handleAdjust(1)}>
|
||||||
|
+1
|
||||||
|
</button>
|
||||||
|
<button className="pop-qty-btn" onClick={() => handleAdjust(10)}>
|
||||||
|
+10
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="pop-qty-hint">미접수 수량: {remainingQty} EA</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 분할접수 안내 */}
|
||||||
|
{quantity < remainingQty && (
|
||||||
|
<div className="pop-accept-info-box">
|
||||||
|
<span className="info-icon">
|
||||||
|
<Info size={20} />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<div className="info-title">분할 접수</div>
|
||||||
|
<div className="info-desc">
|
||||||
|
{quantity}EA 접수 후 {remainingQty - quantity}EA가 접수대기 상태로 남습니다.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pop-modal-footer">
|
||||||
|
<button className="pop-btn pop-btn-outline" style={{ flex: 1 }} onClick={onClose}>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="pop-btn pop-btn-primary"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={quantity <= 0}
|
||||||
|
>
|
||||||
|
접수 ({quantity} EA)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,462 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
import {
|
||||||
|
AppState,
|
||||||
|
ModalState,
|
||||||
|
PanelState,
|
||||||
|
StatusType,
|
||||||
|
ProductionType,
|
||||||
|
WorkOrder,
|
||||||
|
WorkStep,
|
||||||
|
Equipment,
|
||||||
|
Process,
|
||||||
|
} from "./types";
|
||||||
|
import { WORK_ORDERS, EQUIPMENTS, PROCESSES, WORK_STEP_TEMPLATES, STATUS_TEXT } from "./data";
|
||||||
|
|
||||||
|
import { PopHeader } from "./PopHeader";
|
||||||
|
import { PopStatusTabs } from "./PopStatusTabs";
|
||||||
|
import { PopWorkCard } from "./PopWorkCard";
|
||||||
|
import { PopBottomNav } from "./PopBottomNav";
|
||||||
|
import { PopEquipmentModal } from "./PopEquipmentModal";
|
||||||
|
import { PopProcessModal } from "./PopProcessModal";
|
||||||
|
import { PopAcceptModal } from "./PopAcceptModal";
|
||||||
|
import { PopSettingsModal } from "./PopSettingsModal";
|
||||||
|
import { PopProductionPanel } from "./PopProductionPanel";
|
||||||
|
|
||||||
|
export function PopApp() {
|
||||||
|
// 앱 상태
|
||||||
|
const [appState, setAppState] = useState<AppState>({
|
||||||
|
currentStatus: "waiting",
|
||||||
|
selectedEquipment: null,
|
||||||
|
selectedProcess: null,
|
||||||
|
selectedWorkOrder: null,
|
||||||
|
showMyWorkOnly: false,
|
||||||
|
currentWorkSteps: [],
|
||||||
|
currentStepIndex: 0,
|
||||||
|
currentProductionType: "work-order",
|
||||||
|
selectionMode: "single",
|
||||||
|
completionAction: "close",
|
||||||
|
acceptTargetWorkOrder: null,
|
||||||
|
acceptQuantity: 0,
|
||||||
|
theme: "dark",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 모달 상태
|
||||||
|
const [modalState, setModalState] = useState<ModalState>({
|
||||||
|
equipment: false,
|
||||||
|
process: false,
|
||||||
|
accept: false,
|
||||||
|
settings: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 패널 상태
|
||||||
|
const [panelState, setPanelState] = useState<PanelState>({
|
||||||
|
production: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 현재 시간 (hydration 에러 방지를 위해 초기값 null)
|
||||||
|
const [currentDateTime, setCurrentDateTime] = useState<Date | null>(null);
|
||||||
|
const [isClient, setIsClient] = useState(false);
|
||||||
|
|
||||||
|
// 작업지시 목록 (상태 변경을 위해 로컬 상태로 관리)
|
||||||
|
const [workOrders, setWorkOrders] = useState<WorkOrder[]>(WORK_ORDERS);
|
||||||
|
|
||||||
|
// 클라이언트 마운트 확인 및 시계 업데이트
|
||||||
|
useEffect(() => {
|
||||||
|
setIsClient(true);
|
||||||
|
setCurrentDateTime(new Date());
|
||||||
|
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setCurrentDateTime(new Date());
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 로컬 스토리지에서 설정 로드
|
||||||
|
useEffect(() => {
|
||||||
|
const savedSelectionMode = localStorage.getItem("selectionMode") as "single" | "multi" | null;
|
||||||
|
const savedCompletionAction = localStorage.getItem("completionAction") as "close" | "stay" | null;
|
||||||
|
const savedTheme = localStorage.getItem("popTheme") as "dark" | "light" | null;
|
||||||
|
|
||||||
|
setAppState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
selectionMode: savedSelectionMode || "single",
|
||||||
|
completionAction: savedCompletionAction || "close",
|
||||||
|
theme: savedTheme || "dark",
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 상태별 카운트 계산
|
||||||
|
const getStatusCounts = useCallback(() => {
|
||||||
|
const myProcessId = appState.selectedProcess?.id;
|
||||||
|
|
||||||
|
let waitingCount = 0;
|
||||||
|
let pendingAcceptCount = 0;
|
||||||
|
let inProgressCount = 0;
|
||||||
|
let completedCount = 0;
|
||||||
|
|
||||||
|
workOrders.forEach((wo) => {
|
||||||
|
if (!wo.processFlow) return;
|
||||||
|
|
||||||
|
const myProcessIndex = myProcessId
|
||||||
|
? wo.processFlow.findIndex((step) => step.id === myProcessId)
|
||||||
|
: -1;
|
||||||
|
|
||||||
|
if (wo.status === "completed") {
|
||||||
|
completedCount++;
|
||||||
|
} else if (wo.status === "in-progress" && wo.accepted) {
|
||||||
|
inProgressCount++;
|
||||||
|
} else if (myProcessIndex >= 0) {
|
||||||
|
const currentProcessIndex = wo.currentProcessIndex || 0;
|
||||||
|
const myStep = wo.processFlow[myProcessIndex];
|
||||||
|
|
||||||
|
if (currentProcessIndex < myProcessIndex) {
|
||||||
|
waitingCount++;
|
||||||
|
} else if (currentProcessIndex === myProcessIndex && myStep.status !== "completed") {
|
||||||
|
pendingAcceptCount++;
|
||||||
|
} else if (myStep.status === "completed") {
|
||||||
|
completedCount++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (wo.status === "waiting") waitingCount++;
|
||||||
|
else if (wo.status === "in-progress") inProgressCount++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { waitingCount, pendingAcceptCount, inProgressCount, completedCount };
|
||||||
|
}, [workOrders, appState.selectedProcess]);
|
||||||
|
|
||||||
|
// 필터링된 작업 목록
|
||||||
|
const getFilteredWorkOrders = useCallback(() => {
|
||||||
|
const myProcessId = appState.selectedProcess?.id;
|
||||||
|
let filtered: WorkOrder[] = [];
|
||||||
|
|
||||||
|
workOrders.forEach((wo) => {
|
||||||
|
if (!wo.processFlow) return;
|
||||||
|
|
||||||
|
const myProcessIndex = myProcessId
|
||||||
|
? wo.processFlow.findIndex((step) => step.id === myProcessId)
|
||||||
|
: -1;
|
||||||
|
const currentProcessIndex = wo.currentProcessIndex || 0;
|
||||||
|
const myStep = myProcessIndex >= 0 ? wo.processFlow[myProcessIndex] : null;
|
||||||
|
|
||||||
|
switch (appState.currentStatus) {
|
||||||
|
case "waiting":
|
||||||
|
if (myProcessIndex >= 0 && currentProcessIndex < myProcessIndex) {
|
||||||
|
filtered.push(wo);
|
||||||
|
} else if (!myProcessId && wo.status === "waiting") {
|
||||||
|
filtered.push(wo);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "pending-accept":
|
||||||
|
if (
|
||||||
|
myProcessIndex >= 0 &&
|
||||||
|
currentProcessIndex === myProcessIndex &&
|
||||||
|
myStep &&
|
||||||
|
myStep.status !== "completed" &&
|
||||||
|
!wo.accepted
|
||||||
|
) {
|
||||||
|
filtered.push(wo);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "in-progress":
|
||||||
|
if (wo.accepted && wo.status === "in-progress") {
|
||||||
|
filtered.push(wo);
|
||||||
|
} else if (!myProcessId && wo.status === "in-progress") {
|
||||||
|
filtered.push(wo);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "completed":
|
||||||
|
if (wo.status === "completed") {
|
||||||
|
filtered.push(wo);
|
||||||
|
} else if (myStep && myStep.status === "completed") {
|
||||||
|
filtered.push(wo);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 내 작업만 보기 필터
|
||||||
|
if (appState.showMyWorkOnly && myProcessId) {
|
||||||
|
filtered = filtered.filter((wo) => {
|
||||||
|
const mySteps = wo.processFlow.filter((step) => step.id === myProcessId);
|
||||||
|
if (mySteps.length === 0) return false;
|
||||||
|
return !mySteps.every((step) => step.status === "completed");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}, [workOrders, appState.currentStatus, appState.selectedProcess, appState.showMyWorkOnly]);
|
||||||
|
|
||||||
|
// 상태 탭 변경
|
||||||
|
const handleStatusChange = (status: StatusType) => {
|
||||||
|
setAppState((prev) => ({ ...prev, currentStatus: status }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 생산 유형 변경
|
||||||
|
const handleProductionTypeChange = (type: ProductionType) => {
|
||||||
|
setAppState((prev) => ({ ...prev, currentProductionType: type }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 내 작업만 보기 토글
|
||||||
|
const handleMyWorkToggle = () => {
|
||||||
|
setAppState((prev) => ({ ...prev, showMyWorkOnly: !prev.showMyWorkOnly }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 테마 토글
|
||||||
|
const handleThemeToggle = () => {
|
||||||
|
const newTheme = appState.theme === "dark" ? "light" : "dark";
|
||||||
|
setAppState((prev) => ({ ...prev, theme: newTheme }));
|
||||||
|
localStorage.setItem("popTheme", newTheme);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 모달 열기/닫기
|
||||||
|
const openModal = (type: keyof ModalState) => {
|
||||||
|
setModalState((prev) => ({ ...prev, [type]: true }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = (type: keyof ModalState) => {
|
||||||
|
setModalState((prev) => ({ ...prev, [type]: false }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 설비 선택
|
||||||
|
const handleEquipmentSelect = (equipment: Equipment) => {
|
||||||
|
setAppState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
selectedEquipment: equipment,
|
||||||
|
// 공정이 1개면 자동 선택
|
||||||
|
selectedProcess:
|
||||||
|
equipment.processIds.length === 1
|
||||||
|
? PROCESSES.find((p) => p.id === equipment.processIds[0]) || null
|
||||||
|
: null,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 공정 선택
|
||||||
|
const handleProcessSelect = (process: Process) => {
|
||||||
|
setAppState((prev) => ({ ...prev, selectedProcess: process }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 작업 접수 모달 열기
|
||||||
|
const handleOpenAcceptModal = (workOrder: WorkOrder) => {
|
||||||
|
const acceptedQty = workOrder.acceptedQuantity || 0;
|
||||||
|
const remainingQty = workOrder.orderQuantity - acceptedQty;
|
||||||
|
|
||||||
|
setAppState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
acceptTargetWorkOrder: workOrder,
|
||||||
|
acceptQuantity: remainingQty,
|
||||||
|
}));
|
||||||
|
openModal("accept");
|
||||||
|
};
|
||||||
|
|
||||||
|
// 접수 확인
|
||||||
|
const handleConfirmAccept = (quantity: number) => {
|
||||||
|
if (!appState.acceptTargetWorkOrder) return;
|
||||||
|
|
||||||
|
setWorkOrders((prev) =>
|
||||||
|
prev.map((wo) => {
|
||||||
|
if (wo.id === appState.acceptTargetWorkOrder!.id) {
|
||||||
|
const previousAccepted = wo.acceptedQuantity || 0;
|
||||||
|
const newAccepted = previousAccepted + quantity;
|
||||||
|
return {
|
||||||
|
...wo,
|
||||||
|
acceptedQuantity: newAccepted,
|
||||||
|
remainingQuantity: wo.orderQuantity - newAccepted,
|
||||||
|
accepted: true,
|
||||||
|
status: "in-progress" as const,
|
||||||
|
isPartialAccept: newAccepted < wo.orderQuantity,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return wo;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
closeModal("accept");
|
||||||
|
setAppState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
acceptTargetWorkOrder: null,
|
||||||
|
acceptQuantity: 0,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 접수 취소
|
||||||
|
const handleCancelAccept = (workOrderId: string) => {
|
||||||
|
setWorkOrders((prev) =>
|
||||||
|
prev.map((wo) => {
|
||||||
|
if (wo.id === workOrderId) {
|
||||||
|
return {
|
||||||
|
...wo,
|
||||||
|
accepted: false,
|
||||||
|
acceptedQuantity: 0,
|
||||||
|
remainingQuantity: wo.orderQuantity,
|
||||||
|
isPartialAccept: false,
|
||||||
|
status: "waiting" as const,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return wo;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 생산진행 패널 열기
|
||||||
|
const handleOpenProductionPanel = (workOrder: WorkOrder) => {
|
||||||
|
const template = WORK_STEP_TEMPLATES[workOrder.process] || WORK_STEP_TEMPLATES["default"];
|
||||||
|
const workSteps: WorkStep[] = template.map((step) => ({
|
||||||
|
...step,
|
||||||
|
status: "pending" as const,
|
||||||
|
startTime: null,
|
||||||
|
endTime: null,
|
||||||
|
data: {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
setAppState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
selectedWorkOrder: workOrder,
|
||||||
|
currentWorkSteps: workSteps,
|
||||||
|
currentStepIndex: 0,
|
||||||
|
}));
|
||||||
|
setPanelState((prev) => ({ ...prev, production: true }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 생산진행 패널 닫기
|
||||||
|
const handleCloseProductionPanel = () => {
|
||||||
|
setPanelState((prev) => ({ ...prev, production: false }));
|
||||||
|
setAppState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
selectedWorkOrder: null,
|
||||||
|
currentWorkSteps: [],
|
||||||
|
currentStepIndex: 0,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 설정 저장
|
||||||
|
const handleSaveSettings = (selectionMode: "single" | "multi", completionAction: "close" | "stay") => {
|
||||||
|
setAppState((prev) => ({ ...prev, selectionMode, completionAction }));
|
||||||
|
localStorage.setItem("selectionMode", selectionMode);
|
||||||
|
localStorage.setItem("completionAction", completionAction);
|
||||||
|
closeModal("settings");
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusCounts = getStatusCounts();
|
||||||
|
const filteredWorkOrders = getFilteredWorkOrders();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`pop-container ${appState.theme === "light" ? "light" : ""}`}>
|
||||||
|
<div className="pop-app">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<PopHeader
|
||||||
|
currentDateTime={currentDateTime || new Date()}
|
||||||
|
productionType={appState.currentProductionType}
|
||||||
|
selectedEquipment={appState.selectedEquipment}
|
||||||
|
selectedProcess={appState.selectedProcess}
|
||||||
|
showMyWorkOnly={appState.showMyWorkOnly}
|
||||||
|
theme={appState.theme}
|
||||||
|
onProductionTypeChange={handleProductionTypeChange}
|
||||||
|
onEquipmentClick={() => openModal("equipment")}
|
||||||
|
onProcessClick={() => openModal("process")}
|
||||||
|
onMyWorkToggle={handleMyWorkToggle}
|
||||||
|
onSearchClick={() => {
|
||||||
|
/* 조회 */
|
||||||
|
}}
|
||||||
|
onSettingsClick={() => openModal("settings")}
|
||||||
|
onThemeToggle={handleThemeToggle}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 상태 탭 */}
|
||||||
|
<PopStatusTabs
|
||||||
|
currentStatus={appState.currentStatus}
|
||||||
|
counts={statusCounts}
|
||||||
|
onStatusChange={handleStatusChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 메인 콘텐츠 */}
|
||||||
|
<div className="pop-main-content">
|
||||||
|
{filteredWorkOrders.length === 0 ? (
|
||||||
|
<div className="pop-empty-state">
|
||||||
|
<div className="pop-empty-state-text">작업이 없습니다</div>
|
||||||
|
<div className="pop-empty-state-desc">
|
||||||
|
{appState.currentStatus === "waiting" && "대기 중인 작업이 없습니다"}
|
||||||
|
{appState.currentStatus === "pending-accept" && "접수 대기 작업이 없습니다"}
|
||||||
|
{appState.currentStatus === "in-progress" && "진행 중인 작업이 없습니다"}
|
||||||
|
{appState.currentStatus === "completed" && "완료된 작업이 없습니다"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="pop-work-list">
|
||||||
|
{filteredWorkOrders.map((workOrder) => (
|
||||||
|
<PopWorkCard
|
||||||
|
key={workOrder.id}
|
||||||
|
workOrder={workOrder}
|
||||||
|
currentStatus={appState.currentStatus}
|
||||||
|
selectedProcess={appState.selectedProcess}
|
||||||
|
onAccept={() => handleOpenAcceptModal(workOrder)}
|
||||||
|
onCancelAccept={() => handleCancelAccept(workOrder.id)}
|
||||||
|
onStartProduction={() => handleOpenProductionPanel(workOrder)}
|
||||||
|
onClick={() => handleOpenProductionPanel(workOrder)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 하단 네비게이션 */}
|
||||||
|
<PopBottomNav />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 모달들 */}
|
||||||
|
<PopEquipmentModal
|
||||||
|
isOpen={modalState.equipment}
|
||||||
|
equipments={EQUIPMENTS}
|
||||||
|
selectedEquipment={appState.selectedEquipment}
|
||||||
|
onSelect={handleEquipmentSelect}
|
||||||
|
onClose={() => closeModal("equipment")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PopProcessModal
|
||||||
|
isOpen={modalState.process}
|
||||||
|
selectedEquipment={appState.selectedEquipment}
|
||||||
|
selectedProcess={appState.selectedProcess}
|
||||||
|
processes={PROCESSES}
|
||||||
|
onSelect={handleProcessSelect}
|
||||||
|
onClose={() => closeModal("process")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PopAcceptModal
|
||||||
|
isOpen={modalState.accept}
|
||||||
|
workOrder={appState.acceptTargetWorkOrder}
|
||||||
|
quantity={appState.acceptQuantity}
|
||||||
|
onQuantityChange={(qty) => setAppState((prev) => ({ ...prev, acceptQuantity: qty }))}
|
||||||
|
onConfirm={handleConfirmAccept}
|
||||||
|
onClose={() => closeModal("accept")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PopSettingsModal
|
||||||
|
isOpen={modalState.settings}
|
||||||
|
selectionMode={appState.selectionMode}
|
||||||
|
completionAction={appState.completionAction}
|
||||||
|
onSave={handleSaveSettings}
|
||||||
|
onClose={() => closeModal("settings")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 생산진행 패널 */}
|
||||||
|
<PopProductionPanel
|
||||||
|
isOpen={panelState.production}
|
||||||
|
workOrder={appState.selectedWorkOrder}
|
||||||
|
workSteps={appState.currentWorkSteps}
|
||||||
|
currentStepIndex={appState.currentStepIndex}
|
||||||
|
currentDateTime={currentDateTime || new Date()}
|
||||||
|
onStepChange={(index) => setAppState((prev) => ({ ...prev, currentStepIndex: index }))}
|
||||||
|
onStepsUpdate={(steps) => setAppState((prev) => ({ ...prev, currentWorkSteps: steps }))}
|
||||||
|
onClose={handleCloseProductionPanel}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Clock, ClipboardList } from "lucide-react";
|
||||||
|
|
||||||
|
export function PopBottomNav() {
|
||||||
|
const handleHistoryClick = () => {
|
||||||
|
console.log("작업이력 클릭");
|
||||||
|
// TODO: 작업이력 페이지 이동 또는 모달 열기
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRegisterClick = () => {
|
||||||
|
console.log("실적등록 클릭");
|
||||||
|
// TODO: 실적등록 모달 열기
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pop-bottom-nav">
|
||||||
|
<button className="pop-nav-btn secondary" onClick={handleHistoryClick}>
|
||||||
|
<Clock size={18} />
|
||||||
|
작업이력
|
||||||
|
</button>
|
||||||
|
<button className="pop-nav-btn primary" onClick={handleRegisterClick}>
|
||||||
|
<ClipboardList size={18} />
|
||||||
|
실적등록
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { Equipment } from "./types";
|
||||||
|
|
||||||
|
interface PopEquipmentModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
equipments: Equipment[];
|
||||||
|
selectedEquipment: Equipment | null;
|
||||||
|
onSelect: (equipment: Equipment) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PopEquipmentModal({
|
||||||
|
isOpen,
|
||||||
|
equipments,
|
||||||
|
selectedEquipment,
|
||||||
|
onSelect,
|
||||||
|
onClose,
|
||||||
|
}: PopEquipmentModalProps) {
|
||||||
|
const [tempSelected, setTempSelected] = React.useState<Equipment | null>(selectedEquipment);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setTempSelected(selectedEquipment);
|
||||||
|
}, [selectedEquipment, isOpen]);
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (tempSelected) {
|
||||||
|
onSelect(tempSelected);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pop-modal-overlay active" onClick={(e) => e.target === e.currentTarget && onClose()}>
|
||||||
|
<div className="pop-modal">
|
||||||
|
<div className="pop-modal-header">
|
||||||
|
<h2 className="pop-modal-title">설비 선택</h2>
|
||||||
|
<button className="pop-modal-close" onClick={onClose}>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pop-modal-body">
|
||||||
|
<div className="pop-selection-grid">
|
||||||
|
{equipments.map((equip) => (
|
||||||
|
<div
|
||||||
|
key={equip.id}
|
||||||
|
className={`pop-selection-card ${tempSelected?.id === equip.id ? "selected" : ""}`}
|
||||||
|
onClick={() => setTempSelected(equip)}
|
||||||
|
>
|
||||||
|
<div className="pop-selection-card-check">✓</div>
|
||||||
|
<div className="pop-selection-card-name">{equip.name}</div>
|
||||||
|
<div className="pop-selection-card-info">{equip.processNames.join(", ")}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pop-modal-footer">
|
||||||
|
<button className="pop-btn pop-btn-outline" style={{ flex: 1 }} onClick={onClose}>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="pop-btn pop-btn-primary"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={!tempSelected}
|
||||||
|
>
|
||||||
|
확인
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,123 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { Moon, Sun } from "lucide-react";
|
||||||
|
import { Equipment, Process, ProductionType } from "./types";
|
||||||
|
|
||||||
|
interface PopHeaderProps {
|
||||||
|
currentDateTime: Date;
|
||||||
|
productionType: ProductionType;
|
||||||
|
selectedEquipment: Equipment | null;
|
||||||
|
selectedProcess: Process | null;
|
||||||
|
showMyWorkOnly: boolean;
|
||||||
|
theme: "dark" | "light";
|
||||||
|
onProductionTypeChange: (type: ProductionType) => void;
|
||||||
|
onEquipmentClick: () => void;
|
||||||
|
onProcessClick: () => void;
|
||||||
|
onMyWorkToggle: () => void;
|
||||||
|
onSearchClick: () => void;
|
||||||
|
onSettingsClick: () => void;
|
||||||
|
onThemeToggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PopHeader({
|
||||||
|
currentDateTime,
|
||||||
|
productionType,
|
||||||
|
selectedEquipment,
|
||||||
|
selectedProcess,
|
||||||
|
showMyWorkOnly,
|
||||||
|
theme,
|
||||||
|
onProductionTypeChange,
|
||||||
|
onEquipmentClick,
|
||||||
|
onProcessClick,
|
||||||
|
onMyWorkToggle,
|
||||||
|
onSearchClick,
|
||||||
|
onSettingsClick,
|
||||||
|
onThemeToggle,
|
||||||
|
}: PopHeaderProps) {
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const formatDate = (date: Date) => {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(date.getDate()).padStart(2, "0");
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (date: Date) => {
|
||||||
|
const hours = String(date.getHours()).padStart(2, "0");
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||||||
|
return `${hours}:${minutes}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pop-header-container">
|
||||||
|
{/* 1행: 날짜/시간 + 테마 토글 + 작업지시/원자재 */}
|
||||||
|
<div className="pop-top-bar row-1">
|
||||||
|
<div className="pop-datetime">
|
||||||
|
<span className="pop-date">{mounted ? formatDate(currentDateTime) : "----.--.--"}</span>
|
||||||
|
<span className="pop-time">{mounted ? formatTime(currentDateTime) : "--:--"}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테마 토글 버튼 */}
|
||||||
|
<button className="pop-theme-toggle-inline" onClick={onThemeToggle} title="테마 변경">
|
||||||
|
{theme === "dark" ? <Sun size={18} /> : <Moon size={18} />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="pop-spacer" />
|
||||||
|
|
||||||
|
<div className="pop-type-buttons">
|
||||||
|
<button
|
||||||
|
className={`pop-type-btn ${productionType === "work-order" ? "active" : ""}`}
|
||||||
|
onClick={() => onProductionTypeChange("work-order")}
|
||||||
|
>
|
||||||
|
작업지시
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`pop-type-btn ${productionType === "material" ? "active" : ""}`}
|
||||||
|
onClick={() => onProductionTypeChange("material")}
|
||||||
|
>
|
||||||
|
원자재
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2행: 필터 버튼들 */}
|
||||||
|
<div className="pop-top-bar row-2">
|
||||||
|
<button
|
||||||
|
className={`pop-filter-btn ${selectedEquipment ? "active" : ""}`}
|
||||||
|
onClick={onEquipmentClick}
|
||||||
|
>
|
||||||
|
<span>{selectedEquipment?.name || "설비"}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`pop-filter-btn ${selectedProcess ? "active" : ""}`}
|
||||||
|
onClick={onProcessClick}
|
||||||
|
disabled={!selectedEquipment}
|
||||||
|
>
|
||||||
|
<span>{selectedProcess?.name || "공정"}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`pop-filter-btn ${showMyWorkOnly ? "active" : ""}`}
|
||||||
|
onClick={onMyWorkToggle}
|
||||||
|
>
|
||||||
|
내 작업
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="pop-spacer" />
|
||||||
|
|
||||||
|
<button className="pop-filter-btn primary" onClick={onSearchClick}>
|
||||||
|
조회
|
||||||
|
</button>
|
||||||
|
<button className="pop-filter-btn" onClick={onSettingsClick}>
|
||||||
|
설정
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { Equipment, Process } from "./types";
|
||||||
|
|
||||||
|
interface PopProcessModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
selectedEquipment: Equipment | null;
|
||||||
|
selectedProcess: Process | null;
|
||||||
|
processes: Process[];
|
||||||
|
onSelect: (process: Process) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PopProcessModal({
|
||||||
|
isOpen,
|
||||||
|
selectedEquipment,
|
||||||
|
selectedProcess,
|
||||||
|
processes,
|
||||||
|
onSelect,
|
||||||
|
onClose,
|
||||||
|
}: PopProcessModalProps) {
|
||||||
|
const [tempSelected, setTempSelected] = React.useState<Process | null>(selectedProcess);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setTempSelected(selectedProcess);
|
||||||
|
}, [selectedProcess, isOpen]);
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (tempSelected) {
|
||||||
|
onSelect(tempSelected);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen || !selectedEquipment) return null;
|
||||||
|
|
||||||
|
// 선택된 설비의 공정만 필터링
|
||||||
|
const availableProcesses = selectedEquipment.processIds.map((processId, index) => {
|
||||||
|
const process = processes.find((p) => p.id === processId);
|
||||||
|
return {
|
||||||
|
id: processId,
|
||||||
|
name: selectedEquipment.processNames[index],
|
||||||
|
code: process?.code || "",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pop-modal-overlay active" onClick={(e) => e.target === e.currentTarget && onClose()}>
|
||||||
|
<div className="pop-modal">
|
||||||
|
<div className="pop-modal-header">
|
||||||
|
<h2 className="pop-modal-title">공정 선택</h2>
|
||||||
|
<button className="pop-modal-close" onClick={onClose}>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pop-modal-body">
|
||||||
|
<div className="pop-selection-grid">
|
||||||
|
{availableProcesses.map((process) => (
|
||||||
|
<div
|
||||||
|
key={process.id}
|
||||||
|
className={`pop-selection-card ${tempSelected?.id === process.id ? "selected" : ""}`}
|
||||||
|
onClick={() => setTempSelected(process as Process)}
|
||||||
|
>
|
||||||
|
<div className="pop-selection-card-check">✓</div>
|
||||||
|
<div className="pop-selection-card-name">{process.name}</div>
|
||||||
|
<div className="pop-selection-card-info">{process.code}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pop-modal-footer">
|
||||||
|
<button className="pop-btn pop-btn-outline" style={{ flex: 1 }} onClick={onClose}>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="pop-btn pop-btn-primary"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={!tempSelected}
|
||||||
|
>
|
||||||
|
확인
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,346 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { X, Play, Square, ChevronRight } from "lucide-react";
|
||||||
|
import { WorkOrder, WorkStep } from "./types";
|
||||||
|
|
||||||
|
interface PopProductionPanelProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
workOrder: WorkOrder | null;
|
||||||
|
workSteps: WorkStep[];
|
||||||
|
currentStepIndex: number;
|
||||||
|
currentDateTime: Date;
|
||||||
|
onStepChange: (index: number) => void;
|
||||||
|
onStepsUpdate: (steps: WorkStep[]) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PopProductionPanel({
|
||||||
|
isOpen,
|
||||||
|
workOrder,
|
||||||
|
workSteps,
|
||||||
|
currentStepIndex,
|
||||||
|
currentDateTime,
|
||||||
|
onStepChange,
|
||||||
|
onStepsUpdate,
|
||||||
|
onClose,
|
||||||
|
}: PopProductionPanelProps) {
|
||||||
|
if (!isOpen || !workOrder) return null;
|
||||||
|
|
||||||
|
const currentStep = workSteps[currentStepIndex];
|
||||||
|
|
||||||
|
const formatDate = (date: Date) => {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(date.getDate()).padStart(2, "0");
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (date: Date | null) => {
|
||||||
|
if (!date) return "--:--";
|
||||||
|
const d = new Date(date);
|
||||||
|
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStartStep = () => {
|
||||||
|
const newSteps = [...workSteps];
|
||||||
|
newSteps[currentStepIndex] = {
|
||||||
|
...newSteps[currentStepIndex],
|
||||||
|
status: "in-progress",
|
||||||
|
startTime: new Date(),
|
||||||
|
};
|
||||||
|
onStepsUpdate(newSteps);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEndStep = () => {
|
||||||
|
const newSteps = [...workSteps];
|
||||||
|
newSteps[currentStepIndex] = {
|
||||||
|
...newSteps[currentStepIndex],
|
||||||
|
endTime: new Date(),
|
||||||
|
};
|
||||||
|
onStepsUpdate(newSteps);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveAndNext = () => {
|
||||||
|
const newSteps = [...workSteps];
|
||||||
|
const step = newSteps[currentStepIndex];
|
||||||
|
|
||||||
|
// 시간 자동 설정
|
||||||
|
if (!step.startTime) step.startTime = new Date();
|
||||||
|
if (!step.endTime) step.endTime = new Date();
|
||||||
|
step.status = "completed";
|
||||||
|
|
||||||
|
onStepsUpdate(newSteps);
|
||||||
|
|
||||||
|
// 다음 단계로 이동
|
||||||
|
if (currentStepIndex < workSteps.length - 1) {
|
||||||
|
onStepChange(currentStepIndex + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderStepForm = () => {
|
||||||
|
if (!currentStep) return null;
|
||||||
|
|
||||||
|
const isCompleted = currentStep.status === "completed";
|
||||||
|
|
||||||
|
if (currentStep.type === "work" || currentStep.type === "record") {
|
||||||
|
return (
|
||||||
|
<div className="pop-step-form-section">
|
||||||
|
<h4 className="pop-step-form-title">작업 내용 입력</h4>
|
||||||
|
<div className="pop-form-row">
|
||||||
|
<div className="pop-form-group">
|
||||||
|
<label className="pop-form-label">생산수량</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="pop-input"
|
||||||
|
placeholder="0"
|
||||||
|
disabled={isCompleted}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="pop-form-group">
|
||||||
|
<label className="pop-form-label">불량수량</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="pop-input"
|
||||||
|
placeholder="0"
|
||||||
|
disabled={isCompleted}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="pop-form-group">
|
||||||
|
<label className="pop-form-label">비고</label>
|
||||||
|
<textarea
|
||||||
|
className="pop-input"
|
||||||
|
rows={2}
|
||||||
|
placeholder="특이사항을 입력하세요"
|
||||||
|
disabled={isCompleted}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentStep.type === "equipment-check" || currentStep.type === "inspection") {
|
||||||
|
return (
|
||||||
|
<div className="pop-step-form-section">
|
||||||
|
<h4 className="pop-step-form-title">점검 항목</h4>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "var(--spacing-sm)" }}>
|
||||||
|
<label style={{ display: "flex", alignItems: "center", gap: "var(--spacing-sm)" }}>
|
||||||
|
<input type="checkbox" disabled={isCompleted} />
|
||||||
|
<span>장비 상태 확인</span>
|
||||||
|
</label>
|
||||||
|
<label style={{ display: "flex", alignItems: "center", gap: "var(--spacing-sm)" }}>
|
||||||
|
<input type="checkbox" disabled={isCompleted} />
|
||||||
|
<span>안전 장비 착용</span>
|
||||||
|
</label>
|
||||||
|
<label style={{ display: "flex", alignItems: "center", gap: "var(--spacing-sm)" }}>
|
||||||
|
<input type="checkbox" disabled={isCompleted} />
|
||||||
|
<span>작업 환경 확인</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="pop-form-group" style={{ marginTop: "var(--spacing-md)" }}>
|
||||||
|
<label className="pop-form-label">비고</label>
|
||||||
|
<textarea
|
||||||
|
className="pop-input"
|
||||||
|
rows={2}
|
||||||
|
placeholder="점검 결과를 입력하세요"
|
||||||
|
disabled={isCompleted}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pop-step-form-section">
|
||||||
|
<h4 className="pop-step-form-title">작업 메모</h4>
|
||||||
|
<div className="pop-form-group">
|
||||||
|
<textarea
|
||||||
|
className="pop-input"
|
||||||
|
rows={3}
|
||||||
|
placeholder="메모를 입력하세요"
|
||||||
|
disabled={isCompleted}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pop-slide-panel active">
|
||||||
|
<div className="pop-slide-panel-overlay" onClick={onClose} />
|
||||||
|
<div className="pop-slide-panel-content">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="pop-slide-panel-header">
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "var(--spacing-md)" }}>
|
||||||
|
<h2 className="pop-slide-panel-title">생산진행</h2>
|
||||||
|
<span className="pop-badge pop-badge-primary">{workOrder.processName}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "var(--spacing-md)" }}>
|
||||||
|
<div style={{ fontSize: "var(--text-xs)", color: "rgb(var(--text-muted))" }}>
|
||||||
|
<span>{formatDate(currentDateTime)}</span>
|
||||||
|
<span style={{ marginLeft: "var(--spacing-sm)", color: "rgb(var(--neon-cyan))", fontWeight: 700 }}>
|
||||||
|
{formatTime(currentDateTime)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button className="pop-icon-btn" onClick={onClose}>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 작업지시 정보 */}
|
||||||
|
<div className="pop-work-order-info-section">
|
||||||
|
<div className="pop-work-order-info-card">
|
||||||
|
<div className="pop-work-order-info-item">
|
||||||
|
<span className="label">작업지시</span>
|
||||||
|
<span className="value primary">{workOrder.id}</span>
|
||||||
|
</div>
|
||||||
|
<div className="pop-work-order-info-item">
|
||||||
|
<span className="label">품목</span>
|
||||||
|
<span className="value">{workOrder.itemName}</span>
|
||||||
|
</div>
|
||||||
|
<div className="pop-work-order-info-item">
|
||||||
|
<span className="label">규격</span>
|
||||||
|
<span className="value">{workOrder.spec}</span>
|
||||||
|
</div>
|
||||||
|
<div className="pop-work-order-info-item">
|
||||||
|
<span className="label">지시수량</span>
|
||||||
|
<span className="value">{workOrder.orderQuantity} EA</span>
|
||||||
|
</div>
|
||||||
|
<div className="pop-work-order-info-item">
|
||||||
|
<span className="label">생산수량</span>
|
||||||
|
<span className="value">{workOrder.producedQuantity} EA</span>
|
||||||
|
</div>
|
||||||
|
<div className="pop-work-order-info-item">
|
||||||
|
<span className="label">납기일</span>
|
||||||
|
<span className="value">{workOrder.dueDate}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 바디 */}
|
||||||
|
<div className="pop-slide-panel-body">
|
||||||
|
<div className="pop-panel-body-content">
|
||||||
|
{/* 작업순서 사이드바 */}
|
||||||
|
<div className="pop-work-steps-sidebar">
|
||||||
|
<div className="pop-work-steps-header">작업순서</div>
|
||||||
|
<div className="pop-work-steps-list">
|
||||||
|
{workSteps.map((step, index) => (
|
||||||
|
<div
|
||||||
|
key={step.id}
|
||||||
|
className={`pop-work-step-item ${index === currentStepIndex ? "active" : ""} ${step.status}`}
|
||||||
|
onClick={() => onStepChange(index)}
|
||||||
|
>
|
||||||
|
<div className="pop-work-step-number">{index + 1}</div>
|
||||||
|
<div className="pop-work-step-info">
|
||||||
|
<div className="pop-work-step-name">{step.name}</div>
|
||||||
|
<div className="pop-work-step-time">
|
||||||
|
{formatTime(step.startTime)} ~ {formatTime(step.endTime)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className={`pop-work-step-status ${step.status}`}>
|
||||||
|
{step.status === "completed" ? "완료" : step.status === "in-progress" ? "진행중" : "대기"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 작업 콘텐츠 영역 */}
|
||||||
|
<div className="pop-work-content-area">
|
||||||
|
{currentStep && (
|
||||||
|
<>
|
||||||
|
{/* 스텝 헤더 */}
|
||||||
|
<div className="pop-step-header">
|
||||||
|
<h3 className="pop-step-title">{currentStep.name}</h3>
|
||||||
|
<p className="pop-step-description">{currentStep.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 시간 컨트롤 */}
|
||||||
|
{currentStep.status !== "completed" && (
|
||||||
|
<div className="pop-step-time-controls">
|
||||||
|
<button
|
||||||
|
className="pop-time-control-btn start"
|
||||||
|
onClick={handleStartStep}
|
||||||
|
disabled={!!currentStep.startTime}
|
||||||
|
>
|
||||||
|
<Play size={16} />
|
||||||
|
시작 {currentStep.startTime ? formatTime(currentStep.startTime) : ""}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="pop-time-control-btn end"
|
||||||
|
onClick={handleEndStep}
|
||||||
|
disabled={!currentStep.startTime || !!currentStep.endTime}
|
||||||
|
>
|
||||||
|
<Square size={16} />
|
||||||
|
종료 {currentStep.endTime ? formatTime(currentStep.endTime) : ""}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 폼 */}
|
||||||
|
{renderStepForm()}
|
||||||
|
|
||||||
|
{/* 액션 버튼 */}
|
||||||
|
{currentStep.status !== "completed" && (
|
||||||
|
<div style={{ marginTop: "auto", display: "flex", gap: "var(--spacing-md)" }}>
|
||||||
|
<button
|
||||||
|
className="pop-btn pop-btn-outline"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
onClick={() => onStepChange(Math.max(0, currentStepIndex - 1))}
|
||||||
|
disabled={currentStepIndex === 0}
|
||||||
|
>
|
||||||
|
이전
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="pop-btn pop-btn-primary"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
onClick={handleSaveAndNext}
|
||||||
|
>
|
||||||
|
{currentStepIndex === workSteps.length - 1 ? "완료" : "저장 후 다음"}
|
||||||
|
<ChevronRight size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 완료 메시지 */}
|
||||||
|
{currentStep.status === "completed" && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "var(--spacing-md)",
|
||||||
|
background: "rgba(0, 255, 136, 0.1)",
|
||||||
|
border: "1px solid rgba(0, 255, 136, 0.3)",
|
||||||
|
borderRadius: "var(--radius-md)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "var(--spacing-sm)",
|
||||||
|
color: "rgb(var(--success))",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<polyline points="20 6 9 17 4 12" />
|
||||||
|
</svg>
|
||||||
|
<span style={{ fontWeight: 600 }}>작업이 완료되었습니다</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 푸터 */}
|
||||||
|
<div className="pop-slide-panel-footer">
|
||||||
|
<button className="pop-btn pop-btn-outline" style={{ flex: 1 }} onClick={onClose}>
|
||||||
|
닫기
|
||||||
|
</button>
|
||||||
|
<button className="pop-btn pop-btn-primary" style={{ flex: 1 }}>
|
||||||
|
작업 완료
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,135 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
|
||||||
|
interface PopSettingsModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
selectionMode: "single" | "multi";
|
||||||
|
completionAction: "close" | "stay";
|
||||||
|
onSave: (selectionMode: "single" | "multi", completionAction: "close" | "stay") => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PopSettingsModal({
|
||||||
|
isOpen,
|
||||||
|
selectionMode,
|
||||||
|
completionAction,
|
||||||
|
onSave,
|
||||||
|
onClose,
|
||||||
|
}: PopSettingsModalProps) {
|
||||||
|
const [tempSelectionMode, setTempSelectionMode] = useState(selectionMode);
|
||||||
|
const [tempCompletionAction, setTempCompletionAction] = useState(completionAction);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTempSelectionMode(selectionMode);
|
||||||
|
setTempCompletionAction(completionAction);
|
||||||
|
}, [selectionMode, completionAction, isOpen]);
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
onSave(tempSelectionMode, tempCompletionAction);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pop-modal-overlay active" onClick={(e) => e.target === e.currentTarget && onClose()}>
|
||||||
|
<div className="pop-modal">
|
||||||
|
<div className="pop-modal-header">
|
||||||
|
<h2 className="pop-modal-title">설정</h2>
|
||||||
|
<button className="pop-modal-close" onClick={onClose}>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pop-modal-body">
|
||||||
|
{/* 선택 모드 */}
|
||||||
|
<div className="pop-settings-section">
|
||||||
|
<h3 className="pop-settings-title">설비/공정 선택 모드</h3>
|
||||||
|
<div className="pop-mode-options">
|
||||||
|
<label className="pop-mode-option">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="selectionMode"
|
||||||
|
value="single"
|
||||||
|
checked={tempSelectionMode === "single"}
|
||||||
|
onChange={() => setTempSelectionMode("single")}
|
||||||
|
/>
|
||||||
|
<div className="pop-mode-info">
|
||||||
|
<div className="pop-mode-name">단일 선택 모드</div>
|
||||||
|
<div className="pop-mode-desc">
|
||||||
|
설비와 공정을 선택하여 해당 작업만 표시합니다.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label className="pop-mode-option">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="selectionMode"
|
||||||
|
value="multi"
|
||||||
|
checked={tempSelectionMode === "multi"}
|
||||||
|
onChange={() => setTempSelectionMode("multi")}
|
||||||
|
/>
|
||||||
|
<div className="pop-mode-info">
|
||||||
|
<div className="pop-mode-name">다중 선택 모드</div>
|
||||||
|
<div className="pop-mode-desc">
|
||||||
|
모든 설비/공정의 작업을 표시합니다.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pop-settings-divider" />
|
||||||
|
|
||||||
|
{/* 완료 후 동작 */}
|
||||||
|
<div className="pop-settings-section">
|
||||||
|
<h3 className="pop-settings-title">작업 완료 후 동작</h3>
|
||||||
|
<div className="pop-mode-options">
|
||||||
|
<label className="pop-mode-option">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="completionAction"
|
||||||
|
value="close"
|
||||||
|
checked={tempCompletionAction === "close"}
|
||||||
|
onChange={() => setTempCompletionAction("close")}
|
||||||
|
/>
|
||||||
|
<div className="pop-mode-info">
|
||||||
|
<div className="pop-mode-name">패널 닫기</div>
|
||||||
|
<div className="pop-mode-desc">
|
||||||
|
작업 완료 시 생산진행 패널을 자동으로 닫습니다.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label className="pop-mode-option">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="completionAction"
|
||||||
|
value="stay"
|
||||||
|
checked={tempCompletionAction === "stay"}
|
||||||
|
onChange={() => setTempCompletionAction("stay")}
|
||||||
|
/>
|
||||||
|
<div className="pop-mode-info">
|
||||||
|
<div className="pop-mode-name">패널 유지</div>
|
||||||
|
<div className="pop-mode-desc">
|
||||||
|
작업 완료 후에도 패널을 유지합니다.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pop-modal-footer">
|
||||||
|
<button className="pop-btn pop-btn-outline" style={{ flex: 1 }} onClick={onClose}>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button className="pop-btn pop-btn-primary" style={{ flex: 1 }} onClick={handleSave}>
|
||||||
|
저장
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { StatusType } from "./types";
|
||||||
|
|
||||||
|
interface StatusCounts {
|
||||||
|
waitingCount: number;
|
||||||
|
pendingAcceptCount: number;
|
||||||
|
inProgressCount: number;
|
||||||
|
completedCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PopStatusTabsProps {
|
||||||
|
currentStatus: StatusType;
|
||||||
|
counts: StatusCounts;
|
||||||
|
onStatusChange: (status: StatusType) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_CONFIG: {
|
||||||
|
id: StatusType;
|
||||||
|
label: string;
|
||||||
|
detail: string;
|
||||||
|
countKey: keyof StatusCounts;
|
||||||
|
}[] = [
|
||||||
|
{ id: "waiting", label: "대기", detail: "내 공정 이전", countKey: "waitingCount" },
|
||||||
|
{ id: "pending-accept", label: "접수대기", detail: "내 차례", countKey: "pendingAcceptCount" },
|
||||||
|
{ id: "in-progress", label: "진행", detail: "작업중", countKey: "inProgressCount" },
|
||||||
|
{ id: "completed", label: "완료", detail: "처리완료", countKey: "completedCount" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function PopStatusTabs({ currentStatus, counts, onStatusChange }: PopStatusTabsProps) {
|
||||||
|
return (
|
||||||
|
<div className="pop-status-tabs">
|
||||||
|
{STATUS_CONFIG.map((status) => (
|
||||||
|
<div
|
||||||
|
key={status.id}
|
||||||
|
className={`pop-status-tab ${currentStatus === status.id ? "active" : ""}`}
|
||||||
|
onClick={() => onStatusChange(status.id)}
|
||||||
|
>
|
||||||
|
<span className="pop-status-tab-label">{status.label}</span>
|
||||||
|
<span className="pop-status-tab-count">{counts[status.countKey]}</span>
|
||||||
|
<span className="pop-status-tab-detail">{status.detail}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,274 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useRef, useEffect, useState } from "react";
|
||||||
|
import { WorkOrder, Process, StatusType } from "./types";
|
||||||
|
import { STATUS_TEXT } from "./data";
|
||||||
|
|
||||||
|
interface PopWorkCardProps {
|
||||||
|
workOrder: WorkOrder;
|
||||||
|
currentStatus: StatusType;
|
||||||
|
selectedProcess: Process | null;
|
||||||
|
onAccept: () => void;
|
||||||
|
onCancelAccept: () => void;
|
||||||
|
onStartProduction: () => void;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PopWorkCard({
|
||||||
|
workOrder,
|
||||||
|
currentStatus,
|
||||||
|
selectedProcess,
|
||||||
|
onAccept,
|
||||||
|
onCancelAccept,
|
||||||
|
onStartProduction,
|
||||||
|
onClick,
|
||||||
|
}: PopWorkCardProps) {
|
||||||
|
const chipsRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [showLeftBtn, setShowLeftBtn] = useState(false);
|
||||||
|
const [showRightBtn, setShowRightBtn] = useState(false);
|
||||||
|
|
||||||
|
const progress = ((workOrder.producedQuantity / workOrder.orderQuantity) * 100).toFixed(1);
|
||||||
|
const isReturnWork = workOrder.isReturn === true;
|
||||||
|
|
||||||
|
// 공정 스크롤 버튼 표시 여부 확인
|
||||||
|
const checkScrollButtons = () => {
|
||||||
|
const container = chipsRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const isScrollable = container.scrollWidth > container.clientWidth;
|
||||||
|
if (isScrollable) {
|
||||||
|
const scrollLeft = container.scrollLeft;
|
||||||
|
const maxScroll = container.scrollWidth - container.clientWidth;
|
||||||
|
setShowLeftBtn(scrollLeft > 5);
|
||||||
|
setShowRightBtn(scrollLeft < maxScroll - 5);
|
||||||
|
} else {
|
||||||
|
setShowLeftBtn(false);
|
||||||
|
setShowRightBtn(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 현재 공정으로 스크롤
|
||||||
|
const scrollToCurrentProcess = () => {
|
||||||
|
const container = chipsRef.current;
|
||||||
|
if (!container || !workOrder.processFlow) return;
|
||||||
|
|
||||||
|
let targetIndex = -1;
|
||||||
|
|
||||||
|
// 내 공정 우선
|
||||||
|
if (selectedProcess) {
|
||||||
|
targetIndex = workOrder.processFlow.findIndex(
|
||||||
|
(step) =>
|
||||||
|
step.id === selectedProcess.id &&
|
||||||
|
(step.status === "current" || step.status === "pending")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 없으면 현재 진행 중인 공정
|
||||||
|
if (targetIndex === -1) {
|
||||||
|
targetIndex = workOrder.processFlow.findIndex((step) => step.status === "current");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetIndex === -1) return;
|
||||||
|
|
||||||
|
const chips = container.querySelectorAll(".pop-process-chip");
|
||||||
|
if (chips.length > targetIndex) {
|
||||||
|
const targetChip = chips[targetIndex] as HTMLElement;
|
||||||
|
const scrollPos =
|
||||||
|
targetChip.offsetLeft - container.clientWidth / 2 + targetChip.offsetWidth / 2;
|
||||||
|
container.scrollLeft = Math.max(0, scrollPos);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scrollToCurrentProcess();
|
||||||
|
checkScrollButtons();
|
||||||
|
|
||||||
|
const container = chipsRef.current;
|
||||||
|
if (container) {
|
||||||
|
container.addEventListener("scroll", checkScrollButtons);
|
||||||
|
return () => container.removeEventListener("scroll", checkScrollButtons);
|
||||||
|
}
|
||||||
|
}, [workOrder, selectedProcess]);
|
||||||
|
|
||||||
|
const handleScroll = (direction: "left" | "right", e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const container = chipsRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const scrollAmount = 150;
|
||||||
|
container.scrollLeft += direction === "left" ? -scrollAmount : scrollAmount;
|
||||||
|
setTimeout(checkScrollButtons, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 상태 텍스트 결정
|
||||||
|
const statusText =
|
||||||
|
isReturnWork && currentStatus === "pending-accept" ? "리턴" : STATUS_TEXT[workOrder.status];
|
||||||
|
const statusClass = isReturnWork ? "return" : workOrder.status;
|
||||||
|
|
||||||
|
// 완료된 공정 수
|
||||||
|
const completedCount = workOrder.processFlow.filter((s) => s.status === "completed").length;
|
||||||
|
const totalCount = workOrder.processFlow.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`pop-work-card ${isReturnWork ? "return-card" : ""}`}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="pop-work-card-header">
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "var(--spacing-sm)", flex: 1, flexWrap: "wrap" }}>
|
||||||
|
<span className="pop-work-number">{workOrder.id}</span>
|
||||||
|
{isReturnWork && <span className="pop-return-badge">리턴</span>}
|
||||||
|
{workOrder.acceptedQuantity && workOrder.acceptedQuantity > 0 && workOrder.acceptedQuantity < workOrder.orderQuantity && (
|
||||||
|
<span className="pop-partial-badge">
|
||||||
|
{workOrder.acceptedQuantity}/{workOrder.orderQuantity} 접수
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className={`pop-work-status ${statusClass}`}>{statusText}</span>
|
||||||
|
|
||||||
|
{/* 액션 버튼 */}
|
||||||
|
{currentStatus === "pending-accept" && (
|
||||||
|
<div className="pop-work-card-actions">
|
||||||
|
<button
|
||||||
|
className="pop-btn pop-btn-sm pop-btn-primary"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onAccept();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
접수
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{currentStatus === "in-progress" && (
|
||||||
|
<div className="pop-work-card-actions">
|
||||||
|
<button
|
||||||
|
className="pop-btn pop-btn-sm pop-btn-ghost"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onCancelAccept();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
접수취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="pop-btn pop-btn-sm pop-btn-success"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onStartProduction();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
생산진행
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 리턴 정보 배너 */}
|
||||||
|
{isReturnWork && currentStatus === "pending-accept" && (
|
||||||
|
<div className="pop-return-banner">
|
||||||
|
<span className="pop-return-banner-icon">🔄</span>
|
||||||
|
<div>
|
||||||
|
<div className="pop-return-banner-title">
|
||||||
|
{workOrder.returnFromProcessName} 공정에서 리턴됨
|
||||||
|
</div>
|
||||||
|
<div className="pop-return-banner-reason">{workOrder.returnReason || "사유 없음"}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 바디 */}
|
||||||
|
<div className="pop-work-card-body">
|
||||||
|
<div className="pop-work-info-line">
|
||||||
|
<div className="pop-work-info-item">
|
||||||
|
<span className="pop-work-info-label">품목</span>
|
||||||
|
<span className="pop-work-info-value">{workOrder.itemName}</span>
|
||||||
|
</div>
|
||||||
|
<div className="pop-work-info-item">
|
||||||
|
<span className="pop-work-info-label">규격</span>
|
||||||
|
<span className="pop-work-info-value">{workOrder.spec}</span>
|
||||||
|
</div>
|
||||||
|
<div className="pop-work-info-item">
|
||||||
|
<span className="pop-work-info-label">지시</span>
|
||||||
|
<span className="pop-work-info-value">{workOrder.orderQuantity}</span>
|
||||||
|
</div>
|
||||||
|
<div className="pop-work-info-item">
|
||||||
|
<span className="pop-work-info-label">납기</span>
|
||||||
|
<span className="pop-work-info-value">{workOrder.dueDate}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 공정 타임라인 */}
|
||||||
|
<div className="pop-process-timeline">
|
||||||
|
<div className="pop-process-bar">
|
||||||
|
<div className="pop-process-bar-header">
|
||||||
|
<span className="pop-process-bar-label">공정 진행</span>
|
||||||
|
<span className="pop-process-bar-count">
|
||||||
|
<span>{completedCount}</span>/{totalCount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="pop-process-segments">
|
||||||
|
{workOrder.processFlow.map((step, index) => {
|
||||||
|
let segmentClass = "";
|
||||||
|
if (step.status === "completed") segmentClass = "done";
|
||||||
|
else if (step.status === "current") segmentClass = "current";
|
||||||
|
if (selectedProcess && step.id === selectedProcess.id) {
|
||||||
|
segmentClass += " my-work";
|
||||||
|
}
|
||||||
|
return <div key={index} className={`pop-process-segment ${segmentClass}`} />;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pop-process-chips-container">
|
||||||
|
<button
|
||||||
|
className={`pop-process-scroll-btn left ${!showLeftBtn ? "hidden" : ""}`}
|
||||||
|
onClick={(e) => handleScroll("left", e)}
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
<div className="pop-process-chips" ref={chipsRef}>
|
||||||
|
{workOrder.processFlow.map((step, index) => {
|
||||||
|
let chipClass = "";
|
||||||
|
if (step.status === "completed") chipClass = "done";
|
||||||
|
else if (step.status === "current") chipClass = "current";
|
||||||
|
if (selectedProcess && step.id === selectedProcess.id) {
|
||||||
|
chipClass += " my-work";
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div key={index} className={`pop-process-chip ${chipClass}`}>
|
||||||
|
<span className="pop-chip-num">{index + 1}</span>
|
||||||
|
{step.name}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className={`pop-process-scroll-btn right ${!showRightBtn ? "hidden" : ""}`}
|
||||||
|
onClick={(e) => handleScroll("right", e)}
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 진행률 바 */}
|
||||||
|
{workOrder.status !== "completed" && (
|
||||||
|
<div className="pop-work-progress">
|
||||||
|
<div className="pop-progress-info">
|
||||||
|
<span className="pop-progress-text">
|
||||||
|
{workOrder.producedQuantity} / {workOrder.orderQuantity} EA
|
||||||
|
</span>
|
||||||
|
<span className="pop-progress-percent">{progress}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="pop-progress-bar">
|
||||||
|
<div className="pop-progress-fill" style={{ width: `${progress}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { ActivityItem } from "./types";
|
||||||
|
|
||||||
|
interface ActivityListProps {
|
||||||
|
items: ActivityItem[];
|
||||||
|
onMoreClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActivityList({ items, onMoreClick }: ActivityListProps) {
|
||||||
|
return (
|
||||||
|
<div className="pop-dashboard-card">
|
||||||
|
<div className="pop-dashboard-card-header">
|
||||||
|
<h3 className="pop-dashboard-card-title">최근 활동</h3>
|
||||||
|
<button className="pop-dashboard-btn-more" onClick={onMoreClick}>
|
||||||
|
전체보기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="pop-dashboard-activity-list">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div key={item.id} className="pop-dashboard-activity-item">
|
||||||
|
<span className="pop-dashboard-activity-time">{item.time}</span>
|
||||||
|
<span className={`pop-dashboard-activity-dot ${item.category}`} />
|
||||||
|
<div className="pop-dashboard-activity-content">
|
||||||
|
<div className="pop-dashboard-activity-title">{item.title}</div>
|
||||||
|
<div className="pop-dashboard-activity-desc">{item.description}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
interface DashboardFooterProps {
|
||||||
|
companyName: string;
|
||||||
|
version: string;
|
||||||
|
emergencyContact: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardFooter({
|
||||||
|
companyName,
|
||||||
|
version,
|
||||||
|
emergencyContact,
|
||||||
|
}: DashboardFooterProps) {
|
||||||
|
return (
|
||||||
|
<footer className="pop-dashboard-footer">
|
||||||
|
<span>© 2024 {companyName}</span>
|
||||||
|
<span>Version {version}</span>
|
||||||
|
<span>긴급연락: {emergencyContact}</span>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { Moon, Sun } from "lucide-react";
|
||||||
|
import { WeatherInfo, UserInfo, CompanyInfo } from "./types";
|
||||||
|
|
||||||
|
interface DashboardHeaderProps {
|
||||||
|
theme: "dark" | "light";
|
||||||
|
weather: WeatherInfo;
|
||||||
|
user: UserInfo;
|
||||||
|
company: CompanyInfo;
|
||||||
|
onThemeToggle: () => void;
|
||||||
|
onUserClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardHeader({
|
||||||
|
theme,
|
||||||
|
weather,
|
||||||
|
user,
|
||||||
|
company,
|
||||||
|
onThemeToggle,
|
||||||
|
onUserClick,
|
||||||
|
}: DashboardHeaderProps) {
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const [currentTime, setCurrentTime] = useState(new Date());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setCurrentTime(new Date());
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const formatTime = (date: Date) => {
|
||||||
|
const hours = String(date.getHours()).padStart(2, "0");
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||||||
|
const seconds = String(date.getSeconds()).padStart(2, "0");
|
||||||
|
return `${hours}:${minutes}:${seconds}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (date: Date) => {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(date.getDate()).padStart(2, "0");
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="pop-dashboard-header">
|
||||||
|
<div className="pop-dashboard-header-left">
|
||||||
|
<div className="pop-dashboard-time-display">
|
||||||
|
<div className="pop-dashboard-time-main">
|
||||||
|
{mounted ? formatTime(currentTime) : "--:--:--"}
|
||||||
|
</div>
|
||||||
|
<div className="pop-dashboard-time-date">
|
||||||
|
{mounted ? formatDate(currentTime) : "----.--.--"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pop-dashboard-header-right">
|
||||||
|
{/* 테마 토글 */}
|
||||||
|
<button
|
||||||
|
className="pop-dashboard-theme-toggle"
|
||||||
|
onClick={onThemeToggle}
|
||||||
|
title="테마 변경"
|
||||||
|
>
|
||||||
|
{theme === "dark" ? <Moon size={16} /> : <Sun size={16} />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 날씨 정보 */}
|
||||||
|
<div className="pop-dashboard-weather">
|
||||||
|
<span className="pop-dashboard-weather-temp">{weather.temp}</span>
|
||||||
|
<span className="pop-dashboard-weather-desc">{weather.description}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 회사 정보 */}
|
||||||
|
<div className="pop-dashboard-company">
|
||||||
|
<div className="pop-dashboard-company-name">{company.name}</div>
|
||||||
|
<div className="pop-dashboard-company-sub">{company.subTitle}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 사용자 배지 */}
|
||||||
|
<button className="pop-dashboard-user-badge" onClick={onUserClick}>
|
||||||
|
<div className="pop-dashboard-user-avatar">{user.avatar}</div>
|
||||||
|
<div className="pop-dashboard-user-text">
|
||||||
|
<div className="pop-dashboard-user-name">{user.name}</div>
|
||||||
|
<div className="pop-dashboard-user-role">{user.role}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { KpiItem } from "./types";
|
||||||
|
|
||||||
|
interface KpiBarProps {
|
||||||
|
items: KpiItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KpiBar({ items }: KpiBarProps) {
|
||||||
|
const getStrokeDashoffset = (percentage: number) => {
|
||||||
|
const circumference = 264; // 2 * PI * 42
|
||||||
|
return circumference - (circumference * percentage) / 100;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatValue = (value: number) => {
|
||||||
|
if (value >= 1000) {
|
||||||
|
return value.toLocaleString();
|
||||||
|
}
|
||||||
|
return value.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pop-dashboard-kpi-bar">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div key={item.id} className="pop-dashboard-kpi-item">
|
||||||
|
<div className="pop-dashboard-kpi-gauge">
|
||||||
|
<svg viewBox="0 0 100 100" width="52" height="52">
|
||||||
|
<circle
|
||||||
|
className="pop-dashboard-kpi-gauge-bg"
|
||||||
|
cx="50"
|
||||||
|
cy="50"
|
||||||
|
r="42"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
className={`pop-dashboard-kpi-gauge-fill kpi-color-${item.color}`}
|
||||||
|
cx="50"
|
||||||
|
cy="50"
|
||||||
|
r="42"
|
||||||
|
strokeDasharray="264"
|
||||||
|
strokeDashoffset={getStrokeDashoffset(item.percentage)}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className={`pop-dashboard-kpi-gauge-text kpi-color-${item.color}`}>
|
||||||
|
{item.percentage}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="pop-dashboard-kpi-info">
|
||||||
|
<div className="pop-dashboard-kpi-label">{item.label}</div>
|
||||||
|
<div className={`pop-dashboard-kpi-value kpi-color-${item.color}`}>
|
||||||
|
{formatValue(item.value)}
|
||||||
|
<span className="pop-dashboard-kpi-unit">{item.unit}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { MenuItem } from "./types";
|
||||||
|
|
||||||
|
interface MenuGridProps {
|
||||||
|
items: MenuItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MenuGrid({ items }: MenuGridProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleClick = (item: MenuItem) => {
|
||||||
|
if (item.href === "#") {
|
||||||
|
alert(`${item.title} 화면은 준비 중입니다.`);
|
||||||
|
} else {
|
||||||
|
router.push(item.href);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pop-dashboard-menu-grid">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className={`pop-dashboard-menu-card ${item.category}`}
|
||||||
|
onClick={() => handleClick(item)}
|
||||||
|
>
|
||||||
|
<div className="pop-dashboard-menu-header">
|
||||||
|
<div className="pop-dashboard-menu-title">{item.title}</div>
|
||||||
|
<div className={`pop-dashboard-menu-count ${item.category}`}>
|
||||||
|
{item.count}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="pop-dashboard-menu-desc">{item.description}</div>
|
||||||
|
<div className="pop-dashboard-menu-status">{item.status}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
interface NoticeBannerProps {
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NoticeBanner({ text }: NoticeBannerProps) {
|
||||||
|
return (
|
||||||
|
<div className="pop-dashboard-notice-banner">
|
||||||
|
<div className="pop-dashboard-notice-label">공지</div>
|
||||||
|
<div className="pop-dashboard-notice-content">
|
||||||
|
<div className="pop-dashboard-notice-marquee">
|
||||||
|
<span className="pop-dashboard-notice-text">{text}</span>
|
||||||
|
<span className="pop-dashboard-notice-text">{text}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { NoticeItem } from "./types";
|
||||||
|
|
||||||
|
interface NoticeListProps {
|
||||||
|
items: NoticeItem[];
|
||||||
|
onMoreClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NoticeList({ items, onMoreClick }: NoticeListProps) {
|
||||||
|
return (
|
||||||
|
<div className="pop-dashboard-card">
|
||||||
|
<div className="pop-dashboard-card-header">
|
||||||
|
<h3 className="pop-dashboard-card-title">공지사항</h3>
|
||||||
|
<button className="pop-dashboard-btn-more" onClick={onMoreClick}>
|
||||||
|
더보기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="pop-dashboard-notice-list">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div key={item.id} className="pop-dashboard-notice-item">
|
||||||
|
<div className="pop-dashboard-notice-title">{item.title}</div>
|
||||||
|
<div className="pop-dashboard-notice-date">{item.date}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { DashboardHeader } from "./DashboardHeader";
|
||||||
|
import { NoticeBanner } from "./NoticeBanner";
|
||||||
|
import { KpiBar } from "./KpiBar";
|
||||||
|
import { MenuGrid } from "./MenuGrid";
|
||||||
|
import { ActivityList } from "./ActivityList";
|
||||||
|
import { NoticeList } from "./NoticeList";
|
||||||
|
import { DashboardFooter } from "./DashboardFooter";
|
||||||
|
import {
|
||||||
|
KPI_ITEMS,
|
||||||
|
MENU_ITEMS,
|
||||||
|
ACTIVITY_ITEMS,
|
||||||
|
NOTICE_ITEMS,
|
||||||
|
NOTICE_MARQUEE_TEXT,
|
||||||
|
} from "./data";
|
||||||
|
import "./dashboard.css";
|
||||||
|
|
||||||
|
export function PopDashboard() {
|
||||||
|
const [theme, setTheme] = useState<"dark" | "light">("dark");
|
||||||
|
|
||||||
|
// 로컬 스토리지에서 테마 로드
|
||||||
|
useEffect(() => {
|
||||||
|
const savedTheme = localStorage.getItem("popTheme") as "dark" | "light" | null;
|
||||||
|
if (savedTheme) {
|
||||||
|
setTheme(savedTheme);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleThemeToggle = () => {
|
||||||
|
const newTheme = theme === "dark" ? "light" : "dark";
|
||||||
|
setTheme(newTheme);
|
||||||
|
localStorage.setItem("popTheme", newTheme);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUserClick = () => {
|
||||||
|
if (confirm("로그아웃 하시겠습니까?")) {
|
||||||
|
alert("로그아웃되었습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleActivityMore = () => {
|
||||||
|
alert("전체 활동 내역 화면으로 이동합니다.");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNoticeMore = () => {
|
||||||
|
alert("전체 공지사항 화면으로 이동합니다.");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`pop-dashboard-container ${theme === "light" ? "light" : ""}`}>
|
||||||
|
<div className="pop-dashboard">
|
||||||
|
<DashboardHeader
|
||||||
|
theme={theme}
|
||||||
|
weather={{ temp: "18°C", description: "맑음" }}
|
||||||
|
user={{ name: "김철수", role: "생산1팀", avatar: "김" }}
|
||||||
|
company={{ name: "탑씰", subTitle: "현장 관리 시스템" }}
|
||||||
|
onThemeToggle={handleThemeToggle}
|
||||||
|
onUserClick={handleUserClick}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NoticeBanner text={NOTICE_MARQUEE_TEXT} />
|
||||||
|
|
||||||
|
<KpiBar items={KPI_ITEMS} />
|
||||||
|
|
||||||
|
<MenuGrid items={MENU_ITEMS} />
|
||||||
|
|
||||||
|
<div className="pop-dashboard-bottom-section">
|
||||||
|
<ActivityList items={ACTIVITY_ITEMS} onMoreClick={handleActivityMore} />
|
||||||
|
<NoticeList items={NOTICE_ITEMS} onMoreClick={handleNoticeMore} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DashboardFooter
|
||||||
|
companyName="탑씰"
|
||||||
|
version="1.0.0"
|
||||||
|
emergencyContact="042-XXX-XXXX"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,906 @@
|
||||||
|
/* ============================================
|
||||||
|
POP 대시보드 스타일시트
|
||||||
|
다크 모드 (사이버펑크) + 라이트 모드 (소프트 그레이 민트)
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* ========== 다크 모드 (기본) ========== */
|
||||||
|
.pop-dashboard-container {
|
||||||
|
--db-bg-page: #080c15;
|
||||||
|
--db-bg-card: linear-gradient(145deg, rgba(25, 35, 60, 0.9) 0%, rgba(18, 26, 47, 0.95) 100%);
|
||||||
|
--db-bg-card-solid: #121a2f;
|
||||||
|
--db-bg-card-alt: rgba(0, 0, 0, 0.2);
|
||||||
|
--db-bg-elevated: #202d4b;
|
||||||
|
|
||||||
|
--db-accent-primary: #00d4ff;
|
||||||
|
--db-accent-primary-light: #00f0ff;
|
||||||
|
--db-indigo: #4169e1;
|
||||||
|
--db-violet: #8a2be2;
|
||||||
|
--db-mint: #00d4ff;
|
||||||
|
--db-emerald: #00ff88;
|
||||||
|
--db-amber: #ffaa00;
|
||||||
|
--db-rose: #ff3333;
|
||||||
|
|
||||||
|
--db-text-primary: #ffffff;
|
||||||
|
--db-text-secondary: #b4c3dc;
|
||||||
|
--db-text-muted: #64788c;
|
||||||
|
|
||||||
|
--db-border: rgba(40, 55, 85, 1);
|
||||||
|
--db-border-light: rgba(55, 75, 110, 1);
|
||||||
|
|
||||||
|
--db-shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||||
|
--db-shadow-md: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||||
|
--db-shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||||
|
--db-glow-accent: 0 0 20px rgba(0, 212, 255, 0.5), 0 0 40px rgba(0, 212, 255, 0.3);
|
||||||
|
|
||||||
|
--db-radius-sm: 6px;
|
||||||
|
--db-radius-md: 10px;
|
||||||
|
--db-radius-lg: 14px;
|
||||||
|
|
||||||
|
--db-card-border-production: rgba(65, 105, 225, 0.5);
|
||||||
|
--db-card-border-material: rgba(138, 43, 226, 0.5);
|
||||||
|
--db-card-border-quality: rgba(0, 212, 255, 0.5);
|
||||||
|
--db-card-border-equipment: rgba(0, 255, 136, 0.5);
|
||||||
|
--db-card-border-safety: rgba(255, 170, 0, 0.5);
|
||||||
|
|
||||||
|
--db-notice-bg: rgba(255, 170, 0, 0.1);
|
||||||
|
--db-notice-border: rgba(255, 170, 0, 0.3);
|
||||||
|
--db-notice-text: #ffaa00;
|
||||||
|
|
||||||
|
--db-weather-bg: rgba(0, 0, 0, 0.2);
|
||||||
|
--db-weather-border: rgba(40, 55, 85, 1);
|
||||||
|
|
||||||
|
--db-user-badge-bg: rgba(0, 0, 0, 0.3);
|
||||||
|
--db-user-badge-hover: rgba(0, 212, 255, 0.1);
|
||||||
|
|
||||||
|
--db-btn-more-bg: rgba(0, 212, 255, 0.08);
|
||||||
|
--db-btn-more-border: rgba(0, 212, 255, 0.2);
|
||||||
|
--db-btn-more-color: #00d4ff;
|
||||||
|
|
||||||
|
--db-status-bg: rgba(0, 212, 255, 0.1);
|
||||||
|
--db-status-border: rgba(0, 212, 255, 0.2);
|
||||||
|
--db-status-color: #00d4ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 라이트 모드 ========== */
|
||||||
|
.pop-dashboard-container.light {
|
||||||
|
--db-bg-page: #f8f9fb;
|
||||||
|
--db-bg-card: #ffffff;
|
||||||
|
--db-bg-card-solid: #ffffff;
|
||||||
|
--db-bg-card-alt: #f3f5f7;
|
||||||
|
--db-bg-elevated: #fafbfc;
|
||||||
|
|
||||||
|
--db-accent-primary: #14b8a6;
|
||||||
|
--db-accent-primary-light: #2dd4bf;
|
||||||
|
--db-indigo: #6366f1;
|
||||||
|
--db-violet: #8b5cf6;
|
||||||
|
--db-mint: #14b8a6;
|
||||||
|
--db-emerald: #10b981;
|
||||||
|
--db-amber: #f59e0b;
|
||||||
|
--db-rose: #f43f5e;
|
||||||
|
|
||||||
|
--db-text-primary: #1e293b;
|
||||||
|
--db-text-secondary: #475569;
|
||||||
|
--db-text-muted: #94a3b8;
|
||||||
|
|
||||||
|
--db-border: #e2e8f0;
|
||||||
|
--db-border-light: #f1f5f9;
|
||||||
|
|
||||||
|
--db-shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||||
|
--db-shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.07), 0 2px 4px -2px rgba(0, 0, 0, 0.05);
|
||||||
|
--db-shadow-lg: 0 10px 25px -5px rgba(0, 0, 0, 0.08), 0 8px 10px -6px rgba(0, 0, 0, 0.04);
|
||||||
|
--db-glow-accent: none;
|
||||||
|
|
||||||
|
--db-card-border-production: rgba(99, 102, 241, 0.3);
|
||||||
|
--db-card-border-material: rgba(139, 92, 246, 0.3);
|
||||||
|
--db-card-border-quality: rgba(20, 184, 166, 0.3);
|
||||||
|
--db-card-border-equipment: rgba(16, 185, 129, 0.3);
|
||||||
|
--db-card-border-safety: rgba(245, 158, 11, 0.3);
|
||||||
|
|
||||||
|
--db-notice-bg: linear-gradient(90deg, rgba(245, 158, 11, 0.08), rgba(251, 191, 36, 0.05));
|
||||||
|
--db-notice-border: rgba(245, 158, 11, 0.2);
|
||||||
|
--db-notice-text: #475569;
|
||||||
|
|
||||||
|
--db-weather-bg: rgba(20, 184, 166, 0.08);
|
||||||
|
--db-weather-border: rgba(20, 184, 166, 0.25);
|
||||||
|
|
||||||
|
--db-user-badge-bg: #f3f5f7;
|
||||||
|
--db-user-badge-hover: #e2e8f0;
|
||||||
|
|
||||||
|
--db-btn-more-bg: rgba(20, 184, 166, 0.08);
|
||||||
|
--db-btn-more-border: rgba(20, 184, 166, 0.25);
|
||||||
|
--db-btn-more-color: #0d9488;
|
||||||
|
|
||||||
|
--db-status-bg: #f3f5f7;
|
||||||
|
--db-status-border: transparent;
|
||||||
|
--db-status-color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 기본 컨테이너 ========== */
|
||||||
|
.pop-dashboard-container {
|
||||||
|
font-family: 'Pretendard', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
||||||
|
background: var(--db-bg-page);
|
||||||
|
color: var(--db-text-primary);
|
||||||
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh;
|
||||||
|
transition: background 0.3s, color 0.3s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 다크 모드 배경 그리드 */
|
||||||
|
.pop-dashboard-container::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(90deg, rgba(0, 212, 255, 0.03) 0px, transparent 1px, transparent 60px),
|
||||||
|
repeating-linear-gradient(0deg, rgba(0, 212, 255, 0.03) 0px, transparent 1px, transparent 60px),
|
||||||
|
radial-gradient(ellipse 80% 50% at 50% 0%, rgba(0, 212, 255, 0.08) 0%, transparent 60%),
|
||||||
|
linear-gradient(180deg, #080c15 0%, #0a0f1c 50%, #0d1323 100%);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-container.light::before {
|
||||||
|
background: linear-gradient(180deg, #f1f5f9 0%, #f8fafc 50%, #ffffff 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
max-width: 1600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 헤더 ========== */
|
||||||
|
.pop-dashboard-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 14px 24px;
|
||||||
|
background: var(--db-bg-card);
|
||||||
|
border: 1px solid var(--db-border);
|
||||||
|
border-radius: var(--db-radius-lg);
|
||||||
|
box-shadow: var(--db-shadow-sm);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-container:not(.light) .pop-dashboard-header::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 20%;
|
||||||
|
right: 20%;
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(90deg, transparent, var(--db-accent-primary), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-time-display {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-time-main {
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
color: var(--db-accent-primary);
|
||||||
|
line-height: 1;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-container:not(.light) .pop-dashboard-time-main {
|
||||||
|
text-shadow: var(--db-glow-accent);
|
||||||
|
animation: neonFlicker 3s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-time-date {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--db-text-muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 테마 토글 */
|
||||||
|
.pop-dashboard-theme-toggle {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--db-bg-card-alt);
|
||||||
|
border: 1px solid var(--db-border);
|
||||||
|
border-radius: var(--db-radius-md);
|
||||||
|
color: var(--db-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-theme-toggle:hover {
|
||||||
|
border-color: var(--db-accent-primary);
|
||||||
|
color: var(--db-accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 날씨 정보 */
|
||||||
|
.pop-dashboard-weather {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: var(--db-weather-bg);
|
||||||
|
border: 1px solid var(--db-weather-border);
|
||||||
|
border-radius: var(--db-radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-weather-temp {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--db-amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-container.light .pop-dashboard-weather-temp {
|
||||||
|
color: var(--db-accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-weather-desc {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--db-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 회사 정보 */
|
||||||
|
.pop-dashboard-company {
|
||||||
|
padding-right: 14px;
|
||||||
|
border-right: 1px solid var(--db-border);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-company-name {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--db-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-company-sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--db-text-muted);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 사용자 배지 */
|
||||||
|
.pop-dashboard-user-badge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: var(--db-user-badge-bg);
|
||||||
|
border: 1px solid var(--db-border);
|
||||||
|
border-radius: var(--db-radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-user-badge:hover {
|
||||||
|
background: var(--db-user-badge-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-user-badge:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-user-avatar {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
background: linear-gradient(135deg, var(--db-accent-primary), var(--db-emerald));
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-container:not(.light) .pop-dashboard-user-avatar {
|
||||||
|
box-shadow: 0 0 10px rgba(0, 212, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-container.light .pop-dashboard-user-avatar {
|
||||||
|
box-shadow: 0 2px 8px rgba(20, 184, 166, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-user-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--db-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-user-role {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--db-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 공지사항 배너 ========== */
|
||||||
|
.pop-dashboard-notice-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: var(--db-notice-bg);
|
||||||
|
border: 1px solid var(--db-notice-border);
|
||||||
|
border-radius: var(--db-radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-notice-label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--db-bg-page);
|
||||||
|
background: var(--db-amber);
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-container.light .pop-dashboard-notice-label {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-notice-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-notice-marquee {
|
||||||
|
display: flex;
|
||||||
|
animation: dashboardMarquee 30s linear infinite;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-notice-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--db-notice-text);
|
||||||
|
padding-right: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dashboardMarquee {
|
||||||
|
0% { transform: translateX(0); }
|
||||||
|
100% { transform: translateX(-50%); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-notice-banner:hover .pop-dashboard-notice-marquee {
|
||||||
|
animation-play-state: paused;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== KPI 바 ========== */
|
||||||
|
.pop-dashboard-kpi-bar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-kpi-item {
|
||||||
|
background: var(--db-bg-card);
|
||||||
|
border: 1px solid var(--db-border);
|
||||||
|
border-radius: var(--db-radius-lg);
|
||||||
|
padding: 16px 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
box-shadow: var(--db-shadow-sm);
|
||||||
|
transition: all 0.2s;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-container:not(.light) .pop-dashboard-kpi-item::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 1px;
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(0, 212, 255, 0.5), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-kpi-item:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--db-shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-container:not(.light) .pop-dashboard-kpi-item:hover {
|
||||||
|
border-color: rgba(0, 212, 255, 0.3);
|
||||||
|
box-shadow: 0 0 30px rgba(0, 212, 255, 0.1), inset 0 0 30px rgba(0, 212, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-kpi-gauge {
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-kpi-gauge svg {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-kpi-gauge-bg {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--db-border);
|
||||||
|
stroke-width: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-kpi-gauge-fill {
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 5;
|
||||||
|
stroke-linecap: round;
|
||||||
|
transition: stroke-dashoffset 0.5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-container:not(.light) .pop-dashboard-kpi-gauge-fill {
|
||||||
|
filter: drop-shadow(0 0 6px currentColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-container.light .pop-dashboard-kpi-gauge-fill {
|
||||||
|
filter: drop-shadow(0 1px 2px rgba(0,0,0,0.1));
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-kpi-gauge-text {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-kpi-info { flex: 1; }
|
||||||
|
|
||||||
|
.pop-dashboard-kpi-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--db-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-kpi-value {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-kpi-unit {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--db-text-muted);
|
||||||
|
margin-left: 3px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* KPI 색상 */
|
||||||
|
.kpi-color-cyan { color: var(--db-mint); stroke: var(--db-mint); }
|
||||||
|
.kpi-color-emerald { color: var(--db-emerald); stroke: var(--db-emerald); }
|
||||||
|
.kpi-color-rose { color: var(--db-rose); stroke: var(--db-rose); }
|
||||||
|
.kpi-color-amber { color: var(--db-amber); stroke: var(--db-amber); }
|
||||||
|
|
||||||
|
/* ========== 메뉴 그리드 ========== */
|
||||||
|
.pop-dashboard-menu-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-menu-card {
|
||||||
|
background: var(--db-bg-card);
|
||||||
|
border-radius: var(--db-radius-lg);
|
||||||
|
padding: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
box-shadow: var(--db-shadow-sm);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-menu-card:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: var(--db-shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-menu-card:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-menu-card.production { border: 2px solid var(--db-card-border-production); }
|
||||||
|
.pop-dashboard-menu-card.material { border: 2px solid var(--db-card-border-material); }
|
||||||
|
.pop-dashboard-menu-card.quality { border: 2px solid var(--db-card-border-quality); }
|
||||||
|
.pop-dashboard-menu-card.equipment { border: 2px solid var(--db-card-border-equipment); }
|
||||||
|
.pop-dashboard-menu-card.safety { border: 2px solid var(--db-card-border-safety); }
|
||||||
|
|
||||||
|
.pop-dashboard-container:not(.light) .pop-dashboard-menu-card.production:hover { box-shadow: 0 0 20px rgba(65, 105, 225, 0.3); }
|
||||||
|
.pop-dashboard-container:not(.light) .pop-dashboard-menu-card.material:hover { box-shadow: 0 0 20px rgba(138, 43, 226, 0.3); }
|
||||||
|
.pop-dashboard-container:not(.light) .pop-dashboard-menu-card.quality:hover { box-shadow: 0 0 20px rgba(0, 212, 255, 0.3); }
|
||||||
|
.pop-dashboard-container:not(.light) .pop-dashboard-menu-card.equipment:hover { box-shadow: 0 0 20px rgba(0, 255, 136, 0.3); }
|
||||||
|
.pop-dashboard-container:not(.light) .pop-dashboard-menu-card.safety:hover { box-shadow: 0 0 20px rgba(255, 170, 0, 0.3); }
|
||||||
|
|
||||||
|
.pop-dashboard-menu-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-menu-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--db-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-menu-count {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 800;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-container:not(.light) .pop-dashboard-menu-count {
|
||||||
|
text-shadow: 0 0 20px currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-menu-count.production { color: var(--db-indigo); }
|
||||||
|
.pop-dashboard-menu-count.material { color: var(--db-violet); }
|
||||||
|
.pop-dashboard-menu-count.quality { color: var(--db-mint); }
|
||||||
|
.pop-dashboard-menu-count.equipment { color: var(--db-emerald); }
|
||||||
|
.pop-dashboard-menu-count.safety { color: var(--db-amber); }
|
||||||
|
|
||||||
|
.pop-dashboard-menu-desc {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--db-text-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-menu-status {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
background: var(--db-status-bg);
|
||||||
|
border: 1px solid var(--db-status-border);
|
||||||
|
border-radius: 16px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--db-status-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 하단 섹션 ========== */
|
||||||
|
.pop-dashboard-bottom-section {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-card {
|
||||||
|
background: var(--db-bg-card);
|
||||||
|
border: 1px solid var(--db-border);
|
||||||
|
border-radius: var(--db-radius-lg);
|
||||||
|
padding: 18px;
|
||||||
|
box-shadow: var(--db-shadow-sm);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-container:not(.light) .pop-dashboard-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 1px;
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(0, 212, 255, 0.5), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid var(--db-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-card-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--db-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-btn-more {
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: var(--db-btn-more-bg);
|
||||||
|
border: 1px solid var(--db-btn-more-border);
|
||||||
|
color: var(--db-btn-more-color);
|
||||||
|
border-radius: var(--db-radius-sm);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-btn-more:hover {
|
||||||
|
background: var(--db-accent-primary);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--db-accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 활동 리스트 */
|
||||||
|
.pop-dashboard-activity-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-activity-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--db-bg-card-alt);
|
||||||
|
border-radius: var(--db-radius-md);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-container:not(.light) .pop-dashboard-activity-item {
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-container:not(.light) .pop-dashboard-activity-item:hover {
|
||||||
|
background: rgba(0, 212, 255, 0.05);
|
||||||
|
border-color: rgba(0, 212, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-container.light .pop-dashboard-activity-item:hover {
|
||||||
|
background: var(--db-border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-activity-time {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--db-accent-primary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
min-width: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-container:not(.light) .pop-dashboard-activity-time {
|
||||||
|
text-shadow: 0 0 10px rgba(0, 212, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-activity-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-container:not(.light) .pop-dashboard-activity-dot {
|
||||||
|
box-shadow: 0 0 8px currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-activity-dot.production { background: var(--db-indigo); color: var(--db-indigo); }
|
||||||
|
.pop-dashboard-activity-dot.material { background: var(--db-violet); color: var(--db-violet); }
|
||||||
|
.pop-dashboard-activity-dot.quality { background: var(--db-mint); color: var(--db-mint); }
|
||||||
|
.pop-dashboard-activity-dot.equipment { background: var(--db-emerald); color: var(--db-emerald); }
|
||||||
|
|
||||||
|
.pop-dashboard-activity-content { flex: 1; }
|
||||||
|
|
||||||
|
.pop-dashboard-activity-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--db-text-primary);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-activity-desc {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--db-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 공지사항 리스트 */
|
||||||
|
.pop-dashboard-notice-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-notice-item {
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--db-bg-card-alt);
|
||||||
|
border-radius: var(--db-radius-md);
|
||||||
|
transition: all 0.2s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-container:not(.light) .pop-dashboard-notice-item:hover {
|
||||||
|
background: rgba(255, 170, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-container.light .pop-dashboard-notice-item:hover {
|
||||||
|
background: var(--db-border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-notice-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--db-text-primary);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-notice-date {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--db-text-muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 푸터 ========== */
|
||||||
|
.pop-dashboard-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
background: var(--db-bg-card);
|
||||||
|
border: 1px solid var(--db-border);
|
||||||
|
border-radius: var(--db-radius-md);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--db-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 반응형 ========== */
|
||||||
|
|
||||||
|
/* 가로 모드 */
|
||||||
|
@media (orientation: landscape) {
|
||||||
|
.pop-dashboard { padding: 16px 24px; }
|
||||||
|
.pop-dashboard-kpi-bar { grid-template-columns: repeat(4, 1fr) !important; gap: 10px; }
|
||||||
|
.pop-dashboard-kpi-item { padding: 12px 14px; }
|
||||||
|
.pop-dashboard-kpi-gauge { width: 44px; height: 44px; }
|
||||||
|
.pop-dashboard-kpi-gauge svg { width: 44px; height: 44px; }
|
||||||
|
.pop-dashboard-kpi-value { font-size: 20px; }
|
||||||
|
|
||||||
|
.pop-dashboard-menu-grid { grid-template-columns: repeat(5, 1fr) !important; gap: 10px; }
|
||||||
|
.pop-dashboard-menu-card { padding: 14px; display: block; }
|
||||||
|
.pop-dashboard-menu-header { margin-bottom: 8px; display: block; }
|
||||||
|
.pop-dashboard-menu-title { font-size: 13px; }
|
||||||
|
.pop-dashboard-menu-count { font-size: 20px; }
|
||||||
|
.pop-dashboard-menu-desc { display: block; font-size: 10px; }
|
||||||
|
.pop-dashboard-menu-status { margin-top: 8px; }
|
||||||
|
|
||||||
|
.pop-dashboard-bottom-section { grid-template-columns: 2fr 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 세로 모드 */
|
||||||
|
@media (orientation: portrait) {
|
||||||
|
.pop-dashboard { padding: 16px; }
|
||||||
|
.pop-dashboard-kpi-bar { grid-template-columns: repeat(2, 1fr) !important; gap: 10px; }
|
||||||
|
|
||||||
|
.pop-dashboard-menu-grid { grid-template-columns: 1fr !important; gap: 8px; }
|
||||||
|
.pop-dashboard-menu-card {
|
||||||
|
padding: 14px 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.pop-dashboard-menu-header { margin-bottom: 0; display: flex; align-items: center; gap: 12px; }
|
||||||
|
.pop-dashboard-menu-title { font-size: 15px; }
|
||||||
|
.pop-dashboard-menu-count { font-size: 20px; }
|
||||||
|
.pop-dashboard-menu-desc { display: none; }
|
||||||
|
.pop-dashboard-menu-status { margin-top: 0; padding: 5px 12px; font-size: 11px; }
|
||||||
|
|
||||||
|
.pop-dashboard-bottom-section { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 작은 화면 세로 */
|
||||||
|
@media (max-width: 600px) and (orientation: portrait) {
|
||||||
|
.pop-dashboard { padding: 12px; }
|
||||||
|
.pop-dashboard-header { padding: 10px 14px; }
|
||||||
|
.pop-dashboard-time-main { font-size: 20px; }
|
||||||
|
.pop-dashboard-time-date { display: none; }
|
||||||
|
.pop-dashboard-weather { padding: 4px 8px; }
|
||||||
|
.pop-dashboard-weather-temp { font-size: 11px; }
|
||||||
|
.pop-dashboard-weather-desc { display: none; }
|
||||||
|
.pop-dashboard-company { display: none; }
|
||||||
|
.pop-dashboard-user-text { display: none; }
|
||||||
|
.pop-dashboard-user-avatar { width: 30px; height: 30px; }
|
||||||
|
|
||||||
|
.pop-dashboard-notice-banner { padding: 8px 12px; }
|
||||||
|
.pop-dashboard-notice-label { font-size: 9px; }
|
||||||
|
.pop-dashboard-notice-text { font-size: 11px; }
|
||||||
|
|
||||||
|
.pop-dashboard-kpi-item { padding: 12px 14px; gap: 10px; }
|
||||||
|
.pop-dashboard-kpi-gauge { width: 44px; height: 44px; }
|
||||||
|
.pop-dashboard-kpi-gauge svg { width: 44px; height: 44px; }
|
||||||
|
.pop-dashboard-kpi-gauge-text { font-size: 10px; }
|
||||||
|
.pop-dashboard-kpi-label { font-size: 10px; }
|
||||||
|
.pop-dashboard-kpi-value { font-size: 18px; }
|
||||||
|
|
||||||
|
.pop-dashboard-menu-card { padding: 12px 16px; }
|
||||||
|
.pop-dashboard-menu-title { font-size: 14px; }
|
||||||
|
.pop-dashboard-menu-count { font-size: 18px; }
|
||||||
|
.pop-dashboard-menu-status { padding: 4px 10px; font-size: 10px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 작은 화면 가로 */
|
||||||
|
@media (max-width: 600px) and (orientation: landscape) {
|
||||||
|
.pop-dashboard { padding: 10px 16px; }
|
||||||
|
.pop-dashboard-header { padding: 8px 12px; }
|
||||||
|
.pop-dashboard-time-main { font-size: 18px; }
|
||||||
|
.pop-dashboard-time-date { font-size: 10px; }
|
||||||
|
.pop-dashboard-weather { display: none; }
|
||||||
|
.pop-dashboard-company { display: none; }
|
||||||
|
.pop-dashboard-user-text { display: none; }
|
||||||
|
|
||||||
|
.pop-dashboard-notice-banner { padding: 6px 10px; margin-bottom: 10px; }
|
||||||
|
|
||||||
|
.pop-dashboard-kpi-item { padding: 8px 10px; gap: 8px; }
|
||||||
|
.pop-dashboard-kpi-gauge { width: 36px; height: 36px; }
|
||||||
|
.pop-dashboard-kpi-gauge svg { width: 36px; height: 36px; }
|
||||||
|
.pop-dashboard-kpi-gauge-text { font-size: 9px; }
|
||||||
|
.pop-dashboard-kpi-label { font-size: 9px; }
|
||||||
|
.pop-dashboard-kpi-value { font-size: 16px; }
|
||||||
|
|
||||||
|
.pop-dashboard-menu-card { padding: 10px; }
|
||||||
|
.pop-dashboard-menu-title { font-size: 11px; }
|
||||||
|
.pop-dashboard-menu-count { font-size: 16px; }
|
||||||
|
.pop-dashboard-menu-status { margin-top: 4px; padding: 2px 6px; font-size: 8px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 애니메이션 ========== */
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(8px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes neonFlicker {
|
||||||
|
0%, 19%, 21%, 23%, 25%, 54%, 56%, 100% { opacity: 1; }
|
||||||
|
20%, 24%, 55% { opacity: 0.85; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-kpi-item, .pop-dashboard-menu-card, .pop-dashboard-card {
|
||||||
|
animation: fadeIn 0.35s ease-out backwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pop-dashboard-kpi-item:nth-child(1) { animation-delay: 0.05s; }
|
||||||
|
.pop-dashboard-kpi-item:nth-child(2) { animation-delay: 0.1s; }
|
||||||
|
.pop-dashboard-kpi-item:nth-child(3) { animation-delay: 0.15s; }
|
||||||
|
.pop-dashboard-kpi-item:nth-child(4) { animation-delay: 0.2s; }
|
||||||
|
|
||||||
|
.pop-dashboard-menu-card:nth-child(1) { animation-delay: 0.1s; }
|
||||||
|
.pop-dashboard-menu-card:nth-child(2) { animation-delay: 0.15s; }
|
||||||
|
.pop-dashboard-menu-card:nth-child(3) { animation-delay: 0.2s; }
|
||||||
|
.pop-dashboard-menu-card:nth-child(4) { animation-delay: 0.25s; }
|
||||||
|
.pop-dashboard-menu-card:nth-child(5) { animation-delay: 0.3s; }
|
||||||
|
|
||||||
|
/* 스크롤바 */
|
||||||
|
.pop-dashboard-container ::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||||
|
.pop-dashboard-container ::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
.pop-dashboard-container ::-webkit-scrollbar-thumb { background: var(--db-border); border-radius: 3px; }
|
||||||
|
.pop-dashboard-container ::-webkit-scrollbar-thumb:hover { background: var(--db-accent-primary); }
|
||||||
|
|
||||||
|
|
@ -0,0 +1,139 @@
|
||||||
|
// POP 대시보드 샘플 데이터
|
||||||
|
|
||||||
|
import { KpiItem, MenuItem, ActivityItem, NoticeItem } from "./types";
|
||||||
|
|
||||||
|
export const KPI_ITEMS: KpiItem[] = [
|
||||||
|
{
|
||||||
|
id: "achievement",
|
||||||
|
label: "목표 달성률",
|
||||||
|
value: 83.3,
|
||||||
|
unit: "%",
|
||||||
|
percentage: 83,
|
||||||
|
color: "cyan",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "production",
|
||||||
|
label: "금일 생산실적",
|
||||||
|
value: 1250,
|
||||||
|
unit: "EA",
|
||||||
|
percentage: 100,
|
||||||
|
color: "emerald",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "defect",
|
||||||
|
label: "불량률",
|
||||||
|
value: 0.8,
|
||||||
|
unit: "%",
|
||||||
|
percentage: 1,
|
||||||
|
color: "rose",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "equipment",
|
||||||
|
label: "가동 설비",
|
||||||
|
value: 8,
|
||||||
|
unit: "/ 10",
|
||||||
|
percentage: 80,
|
||||||
|
color: "amber",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const MENU_ITEMS: MenuItem[] = [
|
||||||
|
{
|
||||||
|
id: "production",
|
||||||
|
title: "생산관리",
|
||||||
|
count: 5,
|
||||||
|
description: "작업지시 / 생산실적 / 공정관리",
|
||||||
|
status: "진행중",
|
||||||
|
category: "production",
|
||||||
|
href: "/pop/work",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "material",
|
||||||
|
title: "자재관리",
|
||||||
|
count: 12,
|
||||||
|
description: "자재출고 / 재고확인 / 입고처리",
|
||||||
|
status: "대기",
|
||||||
|
category: "material",
|
||||||
|
href: "#",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "quality",
|
||||||
|
title: "품질관리",
|
||||||
|
count: 3,
|
||||||
|
description: "품질검사 / 불량처리 / 검사기록",
|
||||||
|
status: "검사대기",
|
||||||
|
category: "quality",
|
||||||
|
href: "#",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "equipment",
|
||||||
|
title: "설비관리",
|
||||||
|
count: 2,
|
||||||
|
description: "설비현황 / 점검관리 / 고장신고",
|
||||||
|
status: "점검필요",
|
||||||
|
category: "equipment",
|
||||||
|
href: "#",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "safety",
|
||||||
|
title: "안전관리",
|
||||||
|
count: 0,
|
||||||
|
description: "안전점검 / 사고신고 / 안전교육",
|
||||||
|
status: "이상무",
|
||||||
|
category: "safety",
|
||||||
|
href: "#",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ACTIVITY_ITEMS: ActivityItem[] = [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
time: "14:25",
|
||||||
|
title: "생산실적 등록",
|
||||||
|
description: "WO-2024-156 - 500EA 생산완료",
|
||||||
|
category: "production",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
time: "13:50",
|
||||||
|
title: "자재출고",
|
||||||
|
description: "알루미늄 프로파일 A100 - 200EA",
|
||||||
|
category: "material",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "3",
|
||||||
|
time: "11:30",
|
||||||
|
title: "품질검사 완료",
|
||||||
|
description: "LOT-2024-156 합격 (불량 0건)",
|
||||||
|
category: "quality",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "4",
|
||||||
|
time: "09:15",
|
||||||
|
title: "설비점검",
|
||||||
|
description: "5호기 정기점검 완료",
|
||||||
|
category: "equipment",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const NOTICE_ITEMS: NoticeItem[] = [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
title: "금일 15:00 전체 안전교육",
|
||||||
|
date: "2024-01-05",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
title: "3호기 정기점검 안내",
|
||||||
|
date: "2024-01-04",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "3",
|
||||||
|
title: "11월 우수팀 - 생산1팀",
|
||||||
|
date: "2024-01-03",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const NOTICE_MARQUEE_TEXT =
|
||||||
|
"[공지] 금일 오후 3시 전체 안전교육 실시 예정입니다. 전 직원 필참 바랍니다. | [알림] 내일 설비 정기점검으로 인한 3호기 가동 중지 예정 | [안내] 11월 생산실적 우수팀 발표 - 생산1팀 축하드립니다!";
|
||||||
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
export { PopDashboard } from "./PopDashboard";
|
||||||
|
export { DashboardHeader } from "./DashboardHeader";
|
||||||
|
export { NoticeBanner } from "./NoticeBanner";
|
||||||
|
export { KpiBar } from "./KpiBar";
|
||||||
|
export { MenuGrid } from "./MenuGrid";
|
||||||
|
export { ActivityList } from "./ActivityList";
|
||||||
|
export { NoticeList } from "./NoticeList";
|
||||||
|
export { DashboardFooter } from "./DashboardFooter";
|
||||||
|
export * from "./types";
|
||||||
|
export * from "./data";
|
||||||
|
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
// POP 대시보드 타입 정의
|
||||||
|
|
||||||
|
export interface KpiItem {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
unit: string;
|
||||||
|
percentage: number;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MenuItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
count: number;
|
||||||
|
description: string;
|
||||||
|
status: string;
|
||||||
|
category: "production" | "material" | "quality" | "equipment" | "safety";
|
||||||
|
href: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivityItem {
|
||||||
|
id: string;
|
||||||
|
time: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
category: "production" | "material" | "quality" | "equipment";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NoticeItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeatherInfo {
|
||||||
|
temp: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserInfo {
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
avatar: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompanyInfo {
|
||||||
|
name: string;
|
||||||
|
subTitle: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,373 @@
|
||||||
|
// POP 샘플 데이터
|
||||||
|
|
||||||
|
import { Process, Equipment, WorkOrder, WorkStepTemplate } from "./types";
|
||||||
|
|
||||||
|
// 공정 목록
|
||||||
|
export const PROCESSES: Process[] = [
|
||||||
|
{ id: "P001", name: "절단", code: "CUT" },
|
||||||
|
{ id: "P002", name: "용접", code: "WELD" },
|
||||||
|
{ id: "P003", name: "도장", code: "PAINT" },
|
||||||
|
{ id: "P004", name: "조립", code: "ASSY" },
|
||||||
|
{ id: "P005", name: "검사", code: "QC" },
|
||||||
|
{ id: "P006", name: "포장", code: "PACK" },
|
||||||
|
{ id: "P007", name: "프레스", code: "PRESS" },
|
||||||
|
{ id: "P008", name: "연마", code: "POLISH" },
|
||||||
|
{ id: "P009", name: "열처리", code: "HEAT" },
|
||||||
|
{ id: "P010", name: "표면처리", code: "SURFACE" },
|
||||||
|
{ id: "P011", name: "드릴링", code: "DRILL" },
|
||||||
|
{ id: "P012", name: "밀링", code: "MILL" },
|
||||||
|
{ id: "P013", name: "선반", code: "LATHE" },
|
||||||
|
{ id: "P014", name: "연삭", code: "GRIND" },
|
||||||
|
{ id: "P015", name: "측정", code: "MEASURE" },
|
||||||
|
{ id: "P016", name: "세척", code: "CLEAN" },
|
||||||
|
{ id: "P017", name: "건조", code: "DRY" },
|
||||||
|
{ id: "P018", name: "코팅", code: "COAT" },
|
||||||
|
{ id: "P019", name: "라벨링", code: "LABEL" },
|
||||||
|
{ id: "P020", name: "출하검사", code: "FINAL_QC" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 설비 목록
|
||||||
|
export const EQUIPMENTS: Equipment[] = [
|
||||||
|
{
|
||||||
|
id: "E001",
|
||||||
|
name: "CNC-01",
|
||||||
|
processIds: ["P001"],
|
||||||
|
processNames: ["절단"],
|
||||||
|
status: "running",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "E002",
|
||||||
|
name: "CNC-02",
|
||||||
|
processIds: ["P001"],
|
||||||
|
processNames: ["절단"],
|
||||||
|
status: "idle",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "E003",
|
||||||
|
name: "용접기-01",
|
||||||
|
processIds: ["P002"],
|
||||||
|
processNames: ["용접"],
|
||||||
|
status: "running",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "E004",
|
||||||
|
name: "도장라인-A",
|
||||||
|
processIds: ["P003"],
|
||||||
|
processNames: ["도장"],
|
||||||
|
status: "running",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "E005",
|
||||||
|
name: "조립라인-01",
|
||||||
|
processIds: ["P004", "P006"],
|
||||||
|
processNames: ["조립", "포장"],
|
||||||
|
status: "running",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "E006",
|
||||||
|
name: "검사대-01",
|
||||||
|
processIds: ["P005"],
|
||||||
|
processNames: ["검사"],
|
||||||
|
status: "idle",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "E007",
|
||||||
|
name: "작업대-A",
|
||||||
|
processIds: ["P001", "P002", "P004"],
|
||||||
|
processNames: ["절단", "용접", "조립"],
|
||||||
|
status: "idle",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "E008",
|
||||||
|
name: "작업대-B",
|
||||||
|
processIds: ["P003", "P005", "P006"],
|
||||||
|
processNames: ["도장", "검사", "포장"],
|
||||||
|
status: "idle",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 작업순서 템플릿
|
||||||
|
export const WORK_STEP_TEMPLATES: Record<string, WorkStepTemplate[]> = {
|
||||||
|
P001: [
|
||||||
|
// 절단 공정
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "설비 점검",
|
||||||
|
type: "equipment-check",
|
||||||
|
description: "설비 상태 및 안전 점검",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: "원자재 확인",
|
||||||
|
type: "material-check",
|
||||||
|
description: "원자재 수량 및 품질 확인",
|
||||||
|
},
|
||||||
|
{ id: 3, name: "설비 셋팅", type: "setup", description: "절단 조건 설정" },
|
||||||
|
{ id: 4, name: "가공 작업", type: "work", description: "절단 가공 진행" },
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
name: "품질 검사",
|
||||||
|
type: "inspection",
|
||||||
|
description: "가공 결과 품질 검사",
|
||||||
|
},
|
||||||
|
{ id: 6, name: "작업 기록", type: "record", description: "작업 실적 기록" },
|
||||||
|
],
|
||||||
|
P002: [
|
||||||
|
// 용접 공정
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "설비 점검",
|
||||||
|
type: "equipment-check",
|
||||||
|
description: "용접기 및 안전장비 점검",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: "자재 준비",
|
||||||
|
type: "material-check",
|
||||||
|
description: "용접 자재 및 부품 확인",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: "용접 조건 설정",
|
||||||
|
type: "setup",
|
||||||
|
description: "전류, 전압 등 설정",
|
||||||
|
},
|
||||||
|
{ id: 4, name: "용접 작업", type: "work", description: "용접 진행" },
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
name: "용접부 검사",
|
||||||
|
type: "inspection",
|
||||||
|
description: "용접 품질 검사",
|
||||||
|
},
|
||||||
|
{ id: 6, name: "작업 기록", type: "record", description: "용접 실적 기록" },
|
||||||
|
],
|
||||||
|
default: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "작업 준비",
|
||||||
|
type: "preparation",
|
||||||
|
description: "작업 전 준비사항 확인",
|
||||||
|
},
|
||||||
|
{ id: 2, name: "작업 실행", type: "work", description: "작업 진행" },
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: "품질 확인",
|
||||||
|
type: "inspection",
|
||||||
|
description: "작업 결과 확인",
|
||||||
|
},
|
||||||
|
{ id: 4, name: "작업 기록", type: "record", description: "작업 내용 기록" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// 작업지시 목록
|
||||||
|
export const WORK_ORDERS: WorkOrder[] = [
|
||||||
|
{
|
||||||
|
id: "WO-2025-001",
|
||||||
|
itemCode: "PROD-001",
|
||||||
|
itemName: "LCD 패널 A101",
|
||||||
|
spec: "1920x1080",
|
||||||
|
orderQuantity: 500,
|
||||||
|
producedQuantity: 0,
|
||||||
|
status: "waiting",
|
||||||
|
process: "P001",
|
||||||
|
processName: "절단",
|
||||||
|
equipment: "E001",
|
||||||
|
equipmentName: "CNC-01",
|
||||||
|
startDate: "2025-01-06",
|
||||||
|
dueDate: "2025-01-10",
|
||||||
|
priority: "high",
|
||||||
|
accepted: false,
|
||||||
|
processFlow: [
|
||||||
|
{ id: "P001", name: "절단", status: "pending" },
|
||||||
|
{ id: "P007", name: "프레스", status: "pending" },
|
||||||
|
{ id: "P011", name: "드릴링", status: "pending" },
|
||||||
|
{ id: "P002", name: "용접", status: "pending" },
|
||||||
|
{ id: "P008", name: "연마", status: "pending" },
|
||||||
|
{ id: "P003", name: "도장", status: "pending" },
|
||||||
|
{ id: "P004", name: "조립", status: "pending" },
|
||||||
|
{ id: "P005", name: "검사", status: "pending" },
|
||||||
|
{ id: "P006", name: "포장", status: "pending" },
|
||||||
|
],
|
||||||
|
currentProcessIndex: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "WO-2025-002",
|
||||||
|
itemCode: "PROD-002",
|
||||||
|
itemName: "LED 모듈 B202",
|
||||||
|
spec: "500x500",
|
||||||
|
orderQuantity: 300,
|
||||||
|
producedQuantity: 150,
|
||||||
|
status: "in-progress",
|
||||||
|
process: "P002",
|
||||||
|
processName: "용접",
|
||||||
|
equipment: "E003",
|
||||||
|
equipmentName: "용접기-01",
|
||||||
|
startDate: "2025-01-05",
|
||||||
|
dueDate: "2025-01-08",
|
||||||
|
priority: "medium",
|
||||||
|
accepted: true,
|
||||||
|
processFlow: [
|
||||||
|
{ id: "P001", name: "절단", status: "completed" },
|
||||||
|
{ id: "P007", name: "프레스", status: "completed" },
|
||||||
|
{ id: "P011", name: "드릴링", status: "completed" },
|
||||||
|
{ id: "P002", name: "용접", status: "current" },
|
||||||
|
{ id: "P008", name: "연마", status: "pending" },
|
||||||
|
{ id: "P003", name: "도장", status: "pending" },
|
||||||
|
{ id: "P004", name: "조립", status: "pending" },
|
||||||
|
{ id: "P005", name: "검사", status: "pending" },
|
||||||
|
{ id: "P006", name: "포장", status: "pending" },
|
||||||
|
],
|
||||||
|
currentProcessIndex: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "WO-2025-003",
|
||||||
|
itemCode: "PROD-003",
|
||||||
|
itemName: "OLED 디스플레이",
|
||||||
|
spec: "2560x1440",
|
||||||
|
orderQuantity: 200,
|
||||||
|
producedQuantity: 50,
|
||||||
|
status: "in-progress",
|
||||||
|
process: "P004",
|
||||||
|
processName: "조립",
|
||||||
|
equipment: "E005",
|
||||||
|
equipmentName: "조립라인-01",
|
||||||
|
startDate: "2025-01-04",
|
||||||
|
dueDate: "2025-01-09",
|
||||||
|
priority: "high",
|
||||||
|
accepted: true,
|
||||||
|
processFlow: [
|
||||||
|
{ id: "P001", name: "절단", status: "completed" },
|
||||||
|
{ id: "P007", name: "프레스", status: "completed" },
|
||||||
|
{ id: "P002", name: "용접", status: "completed" },
|
||||||
|
{ id: "P003", name: "도장", status: "completed" },
|
||||||
|
{ id: "P004", name: "조립", status: "current" },
|
||||||
|
{ id: "P005", name: "검사", status: "pending" },
|
||||||
|
{ id: "P006", name: "포장", status: "pending" },
|
||||||
|
],
|
||||||
|
currentProcessIndex: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "WO-2025-004",
|
||||||
|
itemCode: "PROD-004",
|
||||||
|
itemName: "스틸 프레임 C300",
|
||||||
|
spec: "800x600",
|
||||||
|
orderQuantity: 150,
|
||||||
|
producedQuantity: 30,
|
||||||
|
status: "in-progress",
|
||||||
|
process: "P005",
|
||||||
|
processName: "검사",
|
||||||
|
equipment: "E006",
|
||||||
|
equipmentName: "검사대-01",
|
||||||
|
startDate: "2025-01-03",
|
||||||
|
dueDate: "2025-01-10",
|
||||||
|
priority: "medium",
|
||||||
|
accepted: false,
|
||||||
|
processFlow: [
|
||||||
|
{ id: "P001", name: "절단", status: "completed" },
|
||||||
|
{ id: "P002", name: "용접", status: "completed" },
|
||||||
|
{ id: "P008", name: "연마", status: "completed" },
|
||||||
|
{ id: "P003", name: "도장", status: "completed" },
|
||||||
|
{ id: "P004", name: "조립", status: "completed" },
|
||||||
|
{ id: "P005", name: "검사", status: "current" },
|
||||||
|
{ id: "P006", name: "포장", status: "pending" },
|
||||||
|
],
|
||||||
|
currentProcessIndex: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "WO-2025-005",
|
||||||
|
itemCode: "PROD-005",
|
||||||
|
itemName: "알루미늄 케이스",
|
||||||
|
spec: "300x400",
|
||||||
|
orderQuantity: 400,
|
||||||
|
producedQuantity: 400,
|
||||||
|
status: "completed",
|
||||||
|
process: "P006",
|
||||||
|
processName: "포장",
|
||||||
|
equipment: "E005",
|
||||||
|
equipmentName: "조립라인-01",
|
||||||
|
startDate: "2025-01-01",
|
||||||
|
dueDate: "2025-01-05",
|
||||||
|
completedDate: "2025-01-05",
|
||||||
|
priority: "high",
|
||||||
|
accepted: true,
|
||||||
|
processFlow: [
|
||||||
|
{ id: "P001", name: "절단", status: "completed" },
|
||||||
|
{ id: "P007", name: "프레스", status: "completed" },
|
||||||
|
{ id: "P008", name: "연마", status: "completed" },
|
||||||
|
{ id: "P003", name: "도장", status: "completed" },
|
||||||
|
{ id: "P004", name: "조립", status: "completed" },
|
||||||
|
{ id: "P005", name: "검사", status: "completed" },
|
||||||
|
{ id: "P006", name: "포장", status: "completed" },
|
||||||
|
],
|
||||||
|
currentProcessIndex: 6,
|
||||||
|
},
|
||||||
|
// 공정 리턴 작업지시
|
||||||
|
{
|
||||||
|
id: "WO-2025-006",
|
||||||
|
itemCode: "PROD-006",
|
||||||
|
itemName: "리턴품 샤프트 F100",
|
||||||
|
spec: "50x300",
|
||||||
|
orderQuantity: 80,
|
||||||
|
producedQuantity: 30,
|
||||||
|
status: "in-progress",
|
||||||
|
process: "P008",
|
||||||
|
processName: "연마",
|
||||||
|
equipment: null,
|
||||||
|
equipmentName: null,
|
||||||
|
startDate: "2025-01-03",
|
||||||
|
dueDate: "2025-01-08",
|
||||||
|
priority: "high",
|
||||||
|
accepted: false,
|
||||||
|
isReturn: true,
|
||||||
|
returnReason: "검사 불합격 - 표면 조도 미달",
|
||||||
|
returnFromProcess: "P005",
|
||||||
|
returnFromProcessName: "검사",
|
||||||
|
processFlow: [
|
||||||
|
{ id: "P001", name: "절단", status: "completed" },
|
||||||
|
{ id: "P002", name: "용접", status: "completed" },
|
||||||
|
{ id: "P008", name: "연마", status: "pending", isReturnTarget: true },
|
||||||
|
{ id: "P014", name: "연삭", status: "pending" },
|
||||||
|
{ id: "P016", name: "세척", status: "pending" },
|
||||||
|
{ id: "P005", name: "검사", status: "pending" },
|
||||||
|
],
|
||||||
|
currentProcessIndex: 2,
|
||||||
|
},
|
||||||
|
// 분할접수 작업지시
|
||||||
|
{
|
||||||
|
id: "WO-2025-007",
|
||||||
|
itemCode: "PROD-007",
|
||||||
|
itemName: "분할접수 테스트 품목",
|
||||||
|
spec: "100x200",
|
||||||
|
orderQuantity: 200,
|
||||||
|
producedQuantity: 50,
|
||||||
|
acceptedQuantity: 50,
|
||||||
|
remainingQuantity: 150,
|
||||||
|
status: "in-progress",
|
||||||
|
process: "P002",
|
||||||
|
processName: "용접",
|
||||||
|
equipment: "E003",
|
||||||
|
equipmentName: "용접기-01",
|
||||||
|
startDate: "2025-01-04",
|
||||||
|
dueDate: "2025-01-10",
|
||||||
|
priority: "normal",
|
||||||
|
accepted: true,
|
||||||
|
isPartialAccept: true,
|
||||||
|
processFlow: [
|
||||||
|
{ id: "P001", name: "절단", status: "completed" },
|
||||||
|
{ id: "P002", name: "용접", status: "current" },
|
||||||
|
{ id: "P003", name: "도장", status: "pending" },
|
||||||
|
{ id: "P004", name: "조립", status: "pending" },
|
||||||
|
{ id: "P005", name: "검사", status: "pending" },
|
||||||
|
{ id: "P006", name: "포장", status: "pending" },
|
||||||
|
],
|
||||||
|
currentProcessIndex: 1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 상태 텍스트 매핑
|
||||||
|
export const STATUS_TEXT: Record<string, string> = {
|
||||||
|
waiting: "대기",
|
||||||
|
"in-progress": "진행중",
|
||||||
|
completed: "완료",
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
export { PopApp } from "./PopApp";
|
||||||
|
export { PopHeader } from "./PopHeader";
|
||||||
|
export { PopStatusTabs } from "./PopStatusTabs";
|
||||||
|
export { PopWorkCard } from "./PopWorkCard";
|
||||||
|
export { PopBottomNav } from "./PopBottomNav";
|
||||||
|
export { PopEquipmentModal } from "./PopEquipmentModal";
|
||||||
|
export { PopProcessModal } from "./PopProcessModal";
|
||||||
|
export { PopAcceptModal } from "./PopAcceptModal";
|
||||||
|
export { PopSettingsModal } from "./PopSettingsModal";
|
||||||
|
export { PopProductionPanel } from "./PopProductionPanel";
|
||||||
|
|
||||||
|
export * from "./types";
|
||||||
|
export * from "./data";
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,104 @@
|
||||||
|
// POP 생산실적관리 타입 정의
|
||||||
|
|
||||||
|
export interface Process {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Equipment {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
processIds: string[];
|
||||||
|
processNames: string[];
|
||||||
|
status: "running" | "idle" | "maintenance";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProcessFlowStep {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status: "pending" | "current" | "completed";
|
||||||
|
isReturnTarget?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkOrder {
|
||||||
|
id: string;
|
||||||
|
itemCode: string;
|
||||||
|
itemName: string;
|
||||||
|
spec: string;
|
||||||
|
orderQuantity: number;
|
||||||
|
producedQuantity: number;
|
||||||
|
status: "waiting" | "in-progress" | "completed";
|
||||||
|
process: string;
|
||||||
|
processName: string;
|
||||||
|
equipment: string | null;
|
||||||
|
equipmentName: string | null;
|
||||||
|
startDate: string;
|
||||||
|
dueDate: string;
|
||||||
|
completedDate?: string;
|
||||||
|
priority: "high" | "medium" | "normal" | "low";
|
||||||
|
accepted: boolean;
|
||||||
|
processFlow: ProcessFlowStep[];
|
||||||
|
currentProcessIndex: number;
|
||||||
|
// 리턴 관련
|
||||||
|
isReturn?: boolean;
|
||||||
|
returnReason?: string;
|
||||||
|
returnFromProcess?: string;
|
||||||
|
returnFromProcessName?: string;
|
||||||
|
// 분할접수 관련
|
||||||
|
acceptedQuantity?: number;
|
||||||
|
remainingQuantity?: number;
|
||||||
|
isPartialAccept?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkStepTemplate {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type:
|
||||||
|
| "equipment-check"
|
||||||
|
| "material-check"
|
||||||
|
| "setup"
|
||||||
|
| "work"
|
||||||
|
| "inspection"
|
||||||
|
| "record"
|
||||||
|
| "preparation";
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkStep extends WorkStepTemplate {
|
||||||
|
status: "pending" | "in-progress" | "completed";
|
||||||
|
startTime: Date | null;
|
||||||
|
endTime: Date | null;
|
||||||
|
data: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StatusType = "waiting" | "pending-accept" | "in-progress" | "completed";
|
||||||
|
|
||||||
|
export type ProductionType = "work-order" | "material";
|
||||||
|
|
||||||
|
export interface AppState {
|
||||||
|
currentStatus: StatusType;
|
||||||
|
selectedEquipment: Equipment | null;
|
||||||
|
selectedProcess: Process | null;
|
||||||
|
selectedWorkOrder: WorkOrder | null;
|
||||||
|
showMyWorkOnly: boolean;
|
||||||
|
currentWorkSteps: WorkStep[];
|
||||||
|
currentStepIndex: number;
|
||||||
|
currentProductionType: ProductionType;
|
||||||
|
selectionMode: "single" | "multi";
|
||||||
|
completionAction: "close" | "stay";
|
||||||
|
acceptTargetWorkOrder: WorkOrder | null;
|
||||||
|
acceptQuantity: number;
|
||||||
|
theme: "dark" | "light";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModalState {
|
||||||
|
equipment: boolean;
|
||||||
|
process: boolean;
|
||||||
|
accept: boolean;
|
||||||
|
settings: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PanelState {
|
||||||
|
production: boolean;
|
||||||
|
}
|
||||||
|
|
@ -32,6 +32,8 @@ interface TableSectionRendererProps {
|
||||||
onTableDataChange: (data: any[]) => void;
|
onTableDataChange: (data: any[]) => void;
|
||||||
// 조건부 테이블용 콜백 (조건별 데이터 변경)
|
// 조건부 테이블용 콜백 (조건별 데이터 변경)
|
||||||
onConditionalTableDataChange?: (conditionValue: string, data: any[]) => void;
|
onConditionalTableDataChange?: (conditionValue: string, data: any[]) => void;
|
||||||
|
// 외부 데이터 (데이터 전달 모달열기 액션으로 전달받은 데이터)
|
||||||
|
groupedData?: Record<string, any>[];
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -337,6 +339,7 @@ export function TableSectionRenderer({
|
||||||
onFormDataChange,
|
onFormDataChange,
|
||||||
onTableDataChange,
|
onTableDataChange,
|
||||||
onConditionalTableDataChange,
|
onConditionalTableDataChange,
|
||||||
|
groupedData,
|
||||||
className,
|
className,
|
||||||
}: TableSectionRendererProps) {
|
}: TableSectionRendererProps) {
|
||||||
// 테이블 데이터 상태 (일반 모드)
|
// 테이블 데이터 상태 (일반 모드)
|
||||||
|
|
@ -373,6 +376,13 @@ export function TableSectionRenderer({
|
||||||
// 초기 데이터 로드 완료 플래그 (무한 루프 방지)
|
// 초기 데이터 로드 완료 플래그 (무한 루프 방지)
|
||||||
const initialDataLoadedRef = React.useRef(false);
|
const initialDataLoadedRef = React.useRef(false);
|
||||||
|
|
||||||
|
// 외부 데이터 로드 완료 플래그
|
||||||
|
const externalDataLoadedRef = React.useRef(false);
|
||||||
|
|
||||||
|
// 외부 데이터 소스 설정
|
||||||
|
const externalDataConfig = tableConfig.externalDataSource;
|
||||||
|
const isExternalDataMode = externalDataConfig?.enabled && externalDataConfig?.tableName;
|
||||||
|
|
||||||
// 조건부 테이블 설정
|
// 조건부 테이블 설정
|
||||||
const conditionalConfig = tableConfig.conditionalTable;
|
const conditionalConfig = tableConfig.conditionalTable;
|
||||||
const isConditionalMode = conditionalConfig?.enabled ?? false;
|
const isConditionalMode = conditionalConfig?.enabled ?? false;
|
||||||
|
|
@ -388,6 +398,56 @@ export function TableSectionRenderer({
|
||||||
// 소스 테이블의 컬럼 라벨 (API에서 동적 로드)
|
// 소스 테이블의 컬럼 라벨 (API에서 동적 로드)
|
||||||
const [sourceColumnLabels, setSourceColumnLabels] = useState<Record<string, string>>({});
|
const [sourceColumnLabels, setSourceColumnLabels] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
// 외부 데이터(groupedData) 처리: 데이터 전달 모달열기 액션으로 전달받은 데이터를 초기 테이블 데이터로 설정
|
||||||
|
useEffect(() => {
|
||||||
|
// 외부 데이터 소스가 활성화되지 않았거나, groupedData가 없으면 스킵
|
||||||
|
if (!isExternalDataMode) return;
|
||||||
|
if (!groupedData || groupedData.length === 0) return;
|
||||||
|
// 이미 로드된 경우 스킵
|
||||||
|
if (externalDataLoadedRef.current) return;
|
||||||
|
|
||||||
|
console.log("[TableSectionRenderer] 외부 데이터 처리 시작:", {
|
||||||
|
externalTableName: externalDataConfig?.tableName,
|
||||||
|
groupedDataCount: groupedData.length,
|
||||||
|
columns: tableConfig.columns?.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
// groupedData를 테이블 컬럼 매핑에 따라 변환
|
||||||
|
const mappedData = groupedData.map((externalRow, index) => {
|
||||||
|
const newRow: Record<string, any> = {
|
||||||
|
_id: `external_${Date.now()}_${index}`,
|
||||||
|
_sourceData: externalRow, // 원본 데이터 보관
|
||||||
|
};
|
||||||
|
|
||||||
|
// 각 컬럼에 대해 externalField 또는 field로 값을 매핑
|
||||||
|
tableConfig.columns?.forEach((col) => {
|
||||||
|
// externalField가 설정되어 있으면 사용, 아니면 field와 동일한 이름으로 매핑
|
||||||
|
const externalFieldName = col.externalField || col.field;
|
||||||
|
const value = externalRow[externalFieldName];
|
||||||
|
|
||||||
|
// 값이 있으면 설정
|
||||||
|
if (value !== undefined) {
|
||||||
|
newRow[col.field] = value;
|
||||||
|
} else if (col.defaultValue !== undefined) {
|
||||||
|
// 기본값이 설정되어 있으면 기본값 사용
|
||||||
|
newRow[col.field] = col.defaultValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return newRow;
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("[TableSectionRenderer] 외부 데이터 매핑 완료:", {
|
||||||
|
mappedCount: mappedData.length,
|
||||||
|
sampleRow: mappedData[0],
|
||||||
|
});
|
||||||
|
|
||||||
|
// 테이블 데이터 설정
|
||||||
|
setTableData(mappedData);
|
||||||
|
onTableDataChange(mappedData);
|
||||||
|
externalDataLoadedRef.current = true;
|
||||||
|
}, [isExternalDataMode, groupedData, tableConfig.columns, externalDataConfig?.tableName, onTableDataChange]);
|
||||||
|
|
||||||
// 소스 테이블의 카테고리 타입 컬럼 목록 로드
|
// 소스 테이블의 카테고리 타입 컬럼 목록 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadCategoryColumns = async () => {
|
const loadCategoryColumns = async () => {
|
||||||
|
|
|
||||||
|
|
@ -2175,6 +2175,7 @@ export function UniversalFormModalComponent({
|
||||||
// 테이블 섹션 데이터를 formData에 저장
|
// 테이블 섹션 데이터를 formData에 저장
|
||||||
handleFieldChange(`_tableSection_${section.id}`, data);
|
handleFieldChange(`_tableSection_${section.id}`, data);
|
||||||
}}
|
}}
|
||||||
|
groupedData={_groupedData}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -710,6 +710,9 @@ interface ColumnSettingItemProps {
|
||||||
displayColumns: string[]; // 검색 설정에서 선택한 표시 컬럼 목록
|
displayColumns: string[]; // 검색 설정에서 선택한 표시 컬럼 목록
|
||||||
sourceTableColumns: { column_name: string; data_type: string; is_nullable: string; comment?: string }[]; // 소스 테이블 컬럼
|
sourceTableColumns: { column_name: string; data_type: string; is_nullable: string; comment?: string }[]; // 소스 테이블 컬럼
|
||||||
sourceTableName: string; // 소스 테이블명
|
sourceTableName: string; // 소스 테이블명
|
||||||
|
externalTableColumns: { column_name: string; data_type: string; is_nullable: string; comment?: string }[]; // 외부 데이터 테이블 컬럼
|
||||||
|
externalTableName?: string; // 외부 데이터 테이블명
|
||||||
|
externalDataEnabled?: boolean; // 외부 데이터 소스 활성화 여부
|
||||||
tables: { table_name: string; comment?: string }[]; // 전체 테이블 목록
|
tables: { table_name: string; comment?: string }[]; // 전체 테이블 목록
|
||||||
tableColumns: Record<string, { column_name: string; data_type: string; is_nullable: string; comment?: string }[]>; // 테이블별 컬럼
|
tableColumns: Record<string, { column_name: string; data_type: string; is_nullable: string; comment?: string }[]>; // 테이블별 컬럼
|
||||||
sections: { id: string; title: string }[]; // 섹션 목록
|
sections: { id: string; title: string }[]; // 섹션 목록
|
||||||
|
|
@ -731,6 +734,9 @@ function ColumnSettingItem({
|
||||||
displayColumns,
|
displayColumns,
|
||||||
sourceTableColumns,
|
sourceTableColumns,
|
||||||
sourceTableName,
|
sourceTableName,
|
||||||
|
externalTableColumns,
|
||||||
|
externalTableName,
|
||||||
|
externalDataEnabled,
|
||||||
tables,
|
tables,
|
||||||
tableColumns,
|
tableColumns,
|
||||||
sections,
|
sections,
|
||||||
|
|
@ -745,6 +751,7 @@ function ColumnSettingItem({
|
||||||
}: ColumnSettingItemProps) {
|
}: ColumnSettingItemProps) {
|
||||||
const [fieldSearchOpen, setFieldSearchOpen] = useState(false);
|
const [fieldSearchOpen, setFieldSearchOpen] = useState(false);
|
||||||
const [sourceFieldSearchOpen, setSourceFieldSearchOpen] = useState(false);
|
const [sourceFieldSearchOpen, setSourceFieldSearchOpen] = useState(false);
|
||||||
|
const [externalFieldSearchOpen, setExternalFieldSearchOpen] = useState(false);
|
||||||
const [parentFieldSearchOpen, setParentFieldSearchOpen] = useState(false);
|
const [parentFieldSearchOpen, setParentFieldSearchOpen] = useState(false);
|
||||||
const [lookupTableOpenMap, setLookupTableOpenMap] = useState<Record<string, boolean>>({});
|
const [lookupTableOpenMap, setLookupTableOpenMap] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
|
@ -1014,6 +1021,88 @@ function ColumnSettingItem({
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 외부 필드 - Combobox (외부 데이터에서 가져올 컬럼) */}
|
||||||
|
{externalDataEnabled && externalTableName && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">외부 필드</Label>
|
||||||
|
<Popover open={externalFieldSearchOpen} onOpenChange={setExternalFieldSearchOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={externalFieldSearchOpen}
|
||||||
|
className="h-8 w-full justify-between text-xs mt-1"
|
||||||
|
disabled={externalTableColumns.length === 0}
|
||||||
|
>
|
||||||
|
<span className="truncate">
|
||||||
|
{col.externalField || "(필드명과 동일)"}
|
||||||
|
</span>
|
||||||
|
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="p-0 w-[250px]" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="외부 필드 검색..." className="text-xs" />
|
||||||
|
<CommandList className="max-h-[200px]">
|
||||||
|
<CommandEmpty className="text-xs py-4 text-center">
|
||||||
|
외부 필드를 찾을 수 없습니다.
|
||||||
|
</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{/* 필드명과 동일 옵션 */}
|
||||||
|
<CommandItem
|
||||||
|
value="__same_as_field__"
|
||||||
|
onSelect={() => {
|
||||||
|
onUpdate({ externalField: undefined });
|
||||||
|
setExternalFieldSearchOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-3 w-3",
|
||||||
|
!col.externalField ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground">(필드명과 동일)</span>
|
||||||
|
</CommandItem>
|
||||||
|
{/* 외부 테이블 컬럼 목록 */}
|
||||||
|
{externalTableColumns.map((extCol) => (
|
||||||
|
<CommandItem
|
||||||
|
key={extCol.column_name}
|
||||||
|
value={extCol.column_name}
|
||||||
|
onSelect={() => {
|
||||||
|
onUpdate({ externalField: extCol.column_name });
|
||||||
|
setExternalFieldSearchOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-3 w-3",
|
||||||
|
col.externalField === extCol.column_name ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col flex-1 min-w-0">
|
||||||
|
<span className="font-medium truncate">{extCol.column_name}</span>
|
||||||
|
{extCol.comment && (
|
||||||
|
<span className="text-[10px] text-muted-foreground truncate">
|
||||||
|
{extCol.comment}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
<p className="text-[10px] text-muted-foreground mt-0.5">
|
||||||
|
외부 데이터({externalTableName})에서 이 컬럼에 매핑할 필드
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 라벨 */}
|
{/* 라벨 */}
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs">라벨</Label>
|
<Label className="text-xs">라벨</Label>
|
||||||
|
|
@ -2450,6 +2539,7 @@ export function TableSectionSettingsModal({
|
||||||
// 테이블 검색 Combobox 상태
|
// 테이블 검색 Combobox 상태
|
||||||
const [tableSearchOpen, setTableSearchOpen] = useState(false);
|
const [tableSearchOpen, setTableSearchOpen] = useState(false);
|
||||||
const [saveTableSearchOpen, setSaveTableSearchOpen] = useState(false);
|
const [saveTableSearchOpen, setSaveTableSearchOpen] = useState(false);
|
||||||
|
const [externalTableSearchOpen, setExternalTableSearchOpen] = useState(false);
|
||||||
|
|
||||||
// 활성 탭
|
// 활성 탭
|
||||||
const [activeTab, setActiveTab] = useState("source");
|
const [activeTab, setActiveTab] = useState("source");
|
||||||
|
|
@ -2623,6 +2713,24 @@ export function TableSectionSettingsModal({
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateExternalDataSource = (updates: Partial<NonNullable<TableSectionConfig["externalDataSource"]>>) => {
|
||||||
|
updateTableConfig({
|
||||||
|
externalDataSource: { ...tableConfig.externalDataSource, enabled: false, tableName: "", ...updates },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 외부 데이터 소스 테이블 컬럼 목록
|
||||||
|
const externalTableColumns = useMemo(() => {
|
||||||
|
return tableColumns[tableConfig.externalDataSource?.tableName || ""] || [];
|
||||||
|
}, [tableColumns, tableConfig.externalDataSource?.tableName]);
|
||||||
|
|
||||||
|
// 외부 데이터 소스 테이블 변경 시 컬럼 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (tableConfig.externalDataSource?.enabled && tableConfig.externalDataSource?.tableName) {
|
||||||
|
onLoadTableColumns(tableConfig.externalDataSource.tableName);
|
||||||
|
}
|
||||||
|
}, [tableConfig.externalDataSource?.enabled, tableConfig.externalDataSource?.tableName, onLoadTableColumns]);
|
||||||
|
|
||||||
// 저장 함수
|
// 저장 함수
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
onSave({
|
onSave({
|
||||||
|
|
@ -2986,6 +3094,98 @@ export function TableSectionSettingsModal({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 외부 데이터 소스 설정 */}
|
||||||
|
<div className="space-y-3 border rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium">외부 데이터 소스</h4>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
"데이터 전달 모달열기" 액션으로 전달받은 데이터를 테이블에 표시합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={tableConfig.externalDataSource?.enabled ?? false}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
if (checked) {
|
||||||
|
updateExternalDataSource({ enabled: true, tableName: "" });
|
||||||
|
} else {
|
||||||
|
updateTableConfig({ externalDataSource: undefined });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tableConfig.externalDataSource?.enabled && (
|
||||||
|
<div className="space-y-3 pt-2">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs font-medium mb-1.5 block">외부 데이터 테이블</Label>
|
||||||
|
<Popover open={externalTableSearchOpen} onOpenChange={setExternalTableSearchOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={externalTableSearchOpen}
|
||||||
|
className="h-9 w-full justify-between text-sm"
|
||||||
|
>
|
||||||
|
{tableConfig.externalDataSource?.tableName || "테이블 선택..."}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="p-0 w-full min-w-[400px]" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="테이블 검색..." className="text-sm" />
|
||||||
|
<CommandList className="max-h-[300px]">
|
||||||
|
<CommandEmpty className="text-sm py-6 text-center">
|
||||||
|
테이블을 찾을 수 없습니다.
|
||||||
|
</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{tables.map((table) => (
|
||||||
|
<CommandItem
|
||||||
|
key={table.table_name}
|
||||||
|
value={table.table_name}
|
||||||
|
onSelect={() => {
|
||||||
|
updateExternalDataSource({ enabled: true, tableName: table.table_name });
|
||||||
|
onLoadTableColumns(table.table_name);
|
||||||
|
setExternalTableSearchOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-sm"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
tableConfig.externalDataSource?.tableName === table.table_name ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{table.table_name}</span>
|
||||||
|
{table.comment && (
|
||||||
|
<span className="text-xs text-muted-foreground">{table.comment}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
<HelpText>이전 화면에서 전달받을 데이터의 원본 테이블을 선택하세요. (예: 수주상세 데이터를 전달받는 경우 sales_order_detail)</HelpText>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tableConfig.externalDataSource?.tableName && externalTableColumns.length > 0 && (
|
||||||
|
<div className="bg-muted/30 rounded-lg p-3">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
선택한 테이블 컬럼: {externalTableColumns.length}개
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground mt-1">
|
||||||
|
"컬럼 설정" 탭에서 각 컬럼의 "외부 필드"를 설정하여 전달받은 데이터의 컬럼을 매핑하세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* 컬럼 설정 탭 */}
|
{/* 컬럼 설정 탭 */}
|
||||||
|
|
@ -3041,6 +3241,9 @@ export function TableSectionSettingsModal({
|
||||||
displayColumns={tableConfig.source.displayColumns || []}
|
displayColumns={tableConfig.source.displayColumns || []}
|
||||||
sourceTableColumns={sourceTableColumns}
|
sourceTableColumns={sourceTableColumns}
|
||||||
sourceTableName={tableConfig.source.tableName}
|
sourceTableName={tableConfig.source.tableName}
|
||||||
|
externalTableColumns={externalTableColumns}
|
||||||
|
externalTableName={tableConfig.externalDataSource?.tableName}
|
||||||
|
externalDataEnabled={tableConfig.externalDataSource?.enabled}
|
||||||
tables={tables}
|
tables={tables}
|
||||||
tableColumns={tableColumns}
|
tableColumns={tableColumns}
|
||||||
sections={otherSections}
|
sections={otherSections}
|
||||||
|
|
|
||||||
|
|
@ -230,6 +230,12 @@ export interface TableSectionConfig {
|
||||||
columnLabels?: Record<string, string>; // 컬럼 라벨 (컬럼명 -> 표시 라벨)
|
columnLabels?: Record<string, string>; // 컬럼 라벨 (컬럼명 -> 표시 라벨)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 1-1. 외부 데이터 소스 설정 (데이터 전달 모달열기 액션으로 전달받은 데이터)
|
||||||
|
externalDataSource?: {
|
||||||
|
enabled: boolean; // 외부 데이터 소스 사용 여부
|
||||||
|
tableName: string; // 전달받을 데이터의 소스 테이블명 (예: sales_order_detail)
|
||||||
|
};
|
||||||
|
|
||||||
// 2. 필터 설정
|
// 2. 필터 설정
|
||||||
filters?: {
|
filters?: {
|
||||||
// 사전 필터 (항상 적용, 사용자에게 노출되지 않음)
|
// 사전 필터 (항상 적용, 사용자에게 노출되지 않음)
|
||||||
|
|
@ -374,6 +380,9 @@ export interface TableColumnConfig {
|
||||||
// 소스 필드 매핑 (검색 모달에서 가져올 컬럼명)
|
// 소스 필드 매핑 (검색 모달에서 가져올 컬럼명)
|
||||||
sourceField?: string; // 소스 테이블의 컬럼명 (미설정 시 field와 동일)
|
sourceField?: string; // 소스 테이블의 컬럼명 (미설정 시 field와 동일)
|
||||||
|
|
||||||
|
// 외부 데이터 필드 매핑 (데이터 전달 모달열기로 전달받은 데이터의 컬럼명)
|
||||||
|
externalField?: string; // 외부 데이터의 컬럼명 (미설정 시 field와 동일)
|
||||||
|
|
||||||
// 편집 설정
|
// 편집 설정
|
||||||
editable?: boolean; // 편집 가능 여부 (기본: true)
|
editable?: boolean; // 편집 가능 여부 (기본: true)
|
||||||
calculated?: boolean; // 계산 필드 여부 (자동 읽기전용)
|
calculated?: boolean; // 계산 필드 여부 (자동 읽기전용)
|
||||||
|
|
|
||||||
|
|
@ -95,24 +95,35 @@ export interface RestAPISourceNodeData {
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 조건 연산자 타입
|
||||||
|
export type ConditionOperator =
|
||||||
|
| "EQUALS"
|
||||||
|
| "NOT_EQUALS"
|
||||||
|
| "GREATER_THAN"
|
||||||
|
| "LESS_THAN"
|
||||||
|
| "GREATER_THAN_OR_EQUAL"
|
||||||
|
| "LESS_THAN_OR_EQUAL"
|
||||||
|
| "LIKE"
|
||||||
|
| "NOT_LIKE"
|
||||||
|
| "IN"
|
||||||
|
| "NOT_IN"
|
||||||
|
| "IS_NULL"
|
||||||
|
| "IS_NOT_NULL"
|
||||||
|
| "EXISTS_IN" // 다른 테이블에 존재함
|
||||||
|
| "NOT_EXISTS_IN"; // 다른 테이블에 존재하지 않음
|
||||||
|
|
||||||
// 조건 분기 노드
|
// 조건 분기 노드
|
||||||
export interface ConditionNodeData {
|
export interface ConditionNodeData {
|
||||||
conditions: Array<{
|
conditions: Array<{
|
||||||
field: string;
|
field: string;
|
||||||
operator:
|
operator: ConditionOperator;
|
||||||
| "EQUALS"
|
|
||||||
| "NOT_EQUALS"
|
|
||||||
| "GREATER_THAN"
|
|
||||||
| "LESS_THAN"
|
|
||||||
| "GREATER_THAN_OR_EQUAL"
|
|
||||||
| "LESS_THAN_OR_EQUAL"
|
|
||||||
| "LIKE"
|
|
||||||
| "NOT_LIKE"
|
|
||||||
| "IN"
|
|
||||||
| "NOT_IN"
|
|
||||||
| "IS_NULL"
|
|
||||||
| "IS_NOT_NULL";
|
|
||||||
value: any;
|
value: any;
|
||||||
|
valueType?: "static" | "field"; // 비교 값 타입
|
||||||
|
// EXISTS_IN / NOT_EXISTS_IN 전용 필드
|
||||||
|
lookupTable?: string; // 조회할 테이블명
|
||||||
|
lookupTableLabel?: string; // 조회할 테이블 라벨
|
||||||
|
lookupField?: string; // 조회할 테이블의 비교 필드
|
||||||
|
lookupFieldLabel?: string; // 조회할 테이블의 비교 필드 라벨
|
||||||
}>;
|
}>;
|
||||||
logic: "AND" | "OR";
|
logic: "AND" | "OR";
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue