2026-01-05 10:05:31 +09:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import { useState, useEffect, useCallback } from "react";
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
|
import { Label } from "@/components/ui/label";
|
|
|
|
|
import {
|
|
|
|
|
Select,
|
|
|
|
|
SelectContent,
|
|
|
|
|
SelectItem,
|
|
|
|
|
SelectTrigger,
|
|
|
|
|
SelectValue,
|
|
|
|
|
} from "@/components/ui/select";
|
|
|
|
|
import {
|
|
|
|
|
Dialog,
|
|
|
|
|
DialogContent,
|
|
|
|
|
DialogDescription,
|
|
|
|
|
DialogFooter,
|
|
|
|
|
DialogHeader,
|
|
|
|
|
DialogTitle,
|
|
|
|
|
} from "@/components/ui/dialog";
|
|
|
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
|
|
|
import { toast } from "sonner";
|
|
|
|
|
import { Plus, ArrowRight, Trash2, Pencil, GitBranch, RefreshCw } from "lucide-react";
|
|
|
|
|
import {
|
|
|
|
|
getDataFlows,
|
|
|
|
|
createDataFlow,
|
|
|
|
|
updateDataFlow,
|
|
|
|
|
deleteDataFlow,
|
|
|
|
|
DataFlow,
|
|
|
|
|
} from "@/lib/api/screenGroup";
|
|
|
|
|
|
|
|
|
|
interface DataFlowPanelProps {
|
|
|
|
|
groupId?: number;
|
|
|
|
|
screenId?: number;
|
|
|
|
|
screens?: Array<{ screen_id: number; screen_name: string }>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default function DataFlowPanel({ groupId, screenId, screens = [] }: DataFlowPanelProps) {
|
|
|
|
|
// 상태 관리
|
|
|
|
|
const [dataFlows, setDataFlows] = useState<DataFlow[]>([]);
|
|
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
|
|
|
const [selectedFlow, setSelectedFlow] = useState<DataFlow | null>(null);
|
|
|
|
|
const [formData, setFormData] = useState({
|
|
|
|
|
source_screen_id: 0,
|
|
|
|
|
source_action: "",
|
|
|
|
|
target_screen_id: 0,
|
|
|
|
|
target_action: "",
|
|
|
|
|
data_mapping: "",
|
|
|
|
|
flow_type: "unidirectional",
|
|
|
|
|
flow_label: "",
|
|
|
|
|
condition_expression: "",
|
|
|
|
|
is_active: "Y",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 데이터 로드
|
|
|
|
|
const loadDataFlows = useCallback(async () => {
|
|
|
|
|
setLoading(true);
|
|
|
|
|
try {
|
|
|
|
|
const response = await getDataFlows(groupId);
|
|
|
|
|
if (response.success && response.data) {
|
|
|
|
|
setDataFlows(response.data);
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("데이터 흐름 로드 실패:", error);
|
|
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
}, [groupId]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
loadDataFlows();
|
|
|
|
|
}, [loadDataFlows]);
|
|
|
|
|
|
|
|
|
|
// 모달 열기
|
|
|
|
|
const openModal = (flow?: DataFlow) => {
|
|
|
|
|
if (flow) {
|
|
|
|
|
setSelectedFlow(flow);
|
|
|
|
|
setFormData({
|
|
|
|
|
source_screen_id: flow.source_screen_id,
|
|
|
|
|
source_action: flow.source_action || "",
|
|
|
|
|
target_screen_id: flow.target_screen_id,
|
|
|
|
|
target_action: flow.target_action || "",
|
|
|
|
|
data_mapping: flow.data_mapping ? JSON.stringify(flow.data_mapping, null, 2) : "",
|
|
|
|
|
flow_type: flow.flow_type,
|
|
|
|
|
flow_label: flow.flow_label || "",
|
|
|
|
|
condition_expression: flow.condition_expression || "",
|
|
|
|
|
is_active: flow.is_active,
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
setSelectedFlow(null);
|
|
|
|
|
setFormData({
|
|
|
|
|
source_screen_id: screenId || 0,
|
|
|
|
|
source_action: "",
|
|
|
|
|
target_screen_id: 0,
|
|
|
|
|
target_action: "",
|
|
|
|
|
data_mapping: "",
|
|
|
|
|
flow_type: "unidirectional",
|
|
|
|
|
flow_label: "",
|
|
|
|
|
condition_expression: "",
|
|
|
|
|
is_active: "Y",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
setIsModalOpen(true);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 저장
|
|
|
|
|
const handleSave = async () => {
|
|
|
|
|
if (!formData.source_screen_id || !formData.target_screen_id) {
|
|
|
|
|
toast.error("소스 화면과 타겟 화면을 선택해주세요.");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
let dataMappingJson = null;
|
|
|
|
|
if (formData.data_mapping) {
|
|
|
|
|
try {
|
|
|
|
|
dataMappingJson = JSON.parse(formData.data_mapping);
|
|
|
|
|
} catch {
|
|
|
|
|
toast.error("데이터 매핑 JSON 형식이 올바르지 않습니다.");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const payload = {
|
|
|
|
|
group_id: groupId,
|
|
|
|
|
source_screen_id: formData.source_screen_id,
|
|
|
|
|
source_action: formData.source_action || null,
|
|
|
|
|
target_screen_id: formData.target_screen_id,
|
|
|
|
|
target_action: formData.target_action || null,
|
|
|
|
|
data_mapping: dataMappingJson,
|
|
|
|
|
flow_type: formData.flow_type,
|
|
|
|
|
flow_label: formData.flow_label || null,
|
|
|
|
|
condition_expression: formData.condition_expression || null,
|
|
|
|
|
is_active: formData.is_active,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let response;
|
|
|
|
|
if (selectedFlow) {
|
|
|
|
|
response = await updateDataFlow(selectedFlow.id, payload);
|
|
|
|
|
} else {
|
|
|
|
|
response = await createDataFlow(payload);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (response.success) {
|
|
|
|
|
toast.success(selectedFlow ? "데이터 흐름이 수정되었습니다." : "데이터 흐름이 추가되었습니다.");
|
|
|
|
|
setIsModalOpen(false);
|
|
|
|
|
loadDataFlows();
|
|
|
|
|
} else {
|
|
|
|
|
toast.error(response.message || "저장에 실패했습니다.");
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
toast.error("저장 중 오류가 발생했습니다.");
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 삭제
|
|
|
|
|
const handleDelete = async (id: number) => {
|
|
|
|
|
if (!confirm("이 데이터 흐름을 삭제하시겠습니까?")) return;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const response = await deleteDataFlow(id);
|
|
|
|
|
if (response.success) {
|
|
|
|
|
toast.success("데이터 흐름이 삭제되었습니다.");
|
|
|
|
|
loadDataFlows();
|
|
|
|
|
} else {
|
|
|
|
|
toast.error(response.message || "삭제에 실패했습니다.");
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
toast.error("삭제 중 오류가 발생했습니다.");
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 액션 옵션
|
|
|
|
|
const sourceActions = [
|
|
|
|
|
{ value: "click", label: "클릭" },
|
|
|
|
|
{ value: "submit", label: "제출" },
|
|
|
|
|
{ value: "select", label: "선택" },
|
|
|
|
|
{ value: "change", label: "변경" },
|
|
|
|
|
{ value: "doubleClick", label: "더블클릭" },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const targetActions = [
|
|
|
|
|
{ value: "open", label: "열기" },
|
|
|
|
|
{ value: "load", label: "로드" },
|
|
|
|
|
{ value: "refresh", label: "새로고침" },
|
|
|
|
|
{ value: "save", label: "저장" },
|
|
|
|
|
{ value: "filter", label: "필터" },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
{/* 헤더 */}
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<GitBranch className="h-4 w-4 text-primary" />
|
|
|
|
|
<h3 className="text-sm font-semibold">데이터 흐름</h3>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<Button variant="ghost" size="sm" onClick={loadDataFlows} className="h-8 w-8 p-0">
|
|
|
|
|
<RefreshCw className="h-3 w-3" />
|
|
|
|
|
</Button>
|
|
|
|
|
<Button variant="outline" size="sm" onClick={() => openModal()} className="h-8 gap-1 text-xs">
|
|
|
|
|
<Plus className="h-3 w-3" />
|
|
|
|
|
추가
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 설명 */}
|
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
|
|
|
화면 간 데이터 전달 흐름을 정의합니다. (예: 목록 화면에서 행 클릭 시 상세 화면 열기)
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
{/* 흐름 목록 */}
|
|
|
|
|
{loading ? (
|
|
|
|
|
<div className="flex items-center justify-center py-8">
|
|
|
|
|
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
|
|
|
|
</div>
|
|
|
|
|
) : dataFlows.length === 0 ? (
|
|
|
|
|
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-8">
|
|
|
|
|
<GitBranch className="h-8 w-8 text-muted-foreground/50" />
|
|
|
|
|
<p className="mt-2 text-xs text-muted-foreground">정의된 데이터 흐름이 없습니다</p>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
{dataFlows.map((flow) => (
|
|
|
|
|
<div
|
|
|
|
|
key={flow.id}
|
|
|
|
|
className="flex items-center justify-between rounded-lg border bg-card p-3 text-xs"
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
|
|
|
|
{/* 소스 화면 */}
|
|
|
|
|
<div className="flex flex-col">
|
|
|
|
|
<span className="font-medium truncate max-w-[100px]">
|
|
|
|
|
{flow.source_screen_name || `화면 ${flow.source_screen_id}`}
|
|
|
|
|
</span>
|
|
|
|
|
{flow.source_action && (
|
|
|
|
|
<span className="text-muted-foreground">{flow.source_action}</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 화살표 */}
|
|
|
|
|
<div className="flex items-center gap-1 text-primary">
|
|
|
|
|
<ArrowRight className="h-4 w-4" />
|
|
|
|
|
{flow.flow_type === "bidirectional" && (
|
|
|
|
|
<ArrowRight className="h-4 w-4 rotate-180" />
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 타겟 화면 */}
|
|
|
|
|
<div className="flex flex-col">
|
|
|
|
|
<span className="font-medium truncate max-w-[100px]">
|
|
|
|
|
{flow.target_screen_name || `화면 ${flow.target_screen_id}`}
|
|
|
|
|
</span>
|
|
|
|
|
{flow.target_action && (
|
|
|
|
|
<span className="text-muted-foreground">{flow.target_action}</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 라벨 */}
|
|
|
|
|
{flow.flow_label && (
|
|
|
|
|
<span className="rounded bg-muted px-2 py-0.5 text-muted-foreground">
|
|
|
|
|
{flow.flow_label}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 액션 버튼 */}
|
|
|
|
|
<div className="flex items-center gap-1 ml-2">
|
|
|
|
|
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => openModal(flow)}>
|
|
|
|
|
<Pencil className="h-3 w-3" />
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
|
|
|
|
className="h-6 w-6 text-destructive hover:text-destructive"
|
|
|
|
|
onClick={() => handleDelete(flow.id)}
|
|
|
|
|
>
|
|
|
|
|
<Trash2 className="h-3 w-3" />
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 추가/수정 모달 */}
|
|
|
|
|
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
|
|
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
|
|
|
|
|
<DialogHeader>
|
|
|
|
|
<DialogTitle className="text-base sm:text-lg">
|
|
|
|
|
{selectedFlow ? "데이터 흐름 수정" : "데이터 흐름 추가"}
|
|
|
|
|
</DialogTitle>
|
|
|
|
|
<DialogDescription className="text-xs sm:text-sm">
|
|
|
|
|
화면 간 데이터 전달 흐름을 설정합니다
|
|
|
|
|
</DialogDescription>
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
{/* 소스 화면 */}
|
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-xs sm:text-sm">소스 화면 *</Label>
|
|
|
|
|
<Select
|
|
|
|
|
value={formData.source_screen_id.toString()}
|
|
|
|
|
onValueChange={(value) => setFormData({ ...formData, source_screen_id: parseInt(value) })}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
|
|
|
|
<SelectValue placeholder="화면 선택" />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
{screens.map((screen) => (
|
|
|
|
|
<SelectItem key={screen.screen_id} value={screen.screen_id.toString()}>
|
|
|
|
|
{screen.screen_name}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-xs sm:text-sm">소스 액션</Label>
|
|
|
|
|
<Select
|
|
|
|
|
value={formData.source_action}
|
|
|
|
|
onValueChange={(value) => setFormData({ ...formData, source_action: value })}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
|
|
|
|
<SelectValue placeholder="액션 선택" />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
{sourceActions.map((action) => (
|
|
|
|
|
<SelectItem key={action.value} value={action.value}>
|
|
|
|
|
{action.label}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 타겟 화면 */}
|
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-xs sm:text-sm">타겟 화면 *</Label>
|
|
|
|
|
<Select
|
|
|
|
|
value={formData.target_screen_id.toString()}
|
|
|
|
|
onValueChange={(value) => setFormData({ ...formData, target_screen_id: parseInt(value) })}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
|
|
|
|
<SelectValue placeholder="화면 선택" />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
{screens.map((screen) => (
|
|
|
|
|
<SelectItem key={screen.screen_id} value={screen.screen_id.toString()}>
|
|
|
|
|
{screen.screen_name}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-xs sm:text-sm">타겟 액션</Label>
|
|
|
|
|
<Select
|
|
|
|
|
value={formData.target_action}
|
|
|
|
|
onValueChange={(value) => setFormData({ ...formData, target_action: value })}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
|
|
|
|
<SelectValue placeholder="액션 선택" />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
{targetActions.map((action) => (
|
|
|
|
|
<SelectItem key={action.value} value={action.value}>
|
|
|
|
|
{action.label}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 흐름 설정 */}
|
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-xs sm:text-sm">흐름 타입</Label>
|
|
|
|
|
<Select
|
|
|
|
|
value={formData.flow_type}
|
|
|
|
|
onValueChange={(value) => setFormData({ ...formData, flow_type: value })}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
|
|
|
|
<SelectValue />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
<SelectItem value="unidirectional">단방향</SelectItem>
|
|
|
|
|
<SelectItem value="bidirectional">양방향</SelectItem>
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-xs sm:text-sm">흐름 라벨</Label>
|
|
|
|
|
<Input
|
|
|
|
|
value={formData.flow_label}
|
|
|
|
|
onChange={(e) => setFormData({ ...formData, flow_label: e.target.value })}
|
|
|
|
|
placeholder="예: 상세 보기"
|
|
|
|
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 데이터 매핑 */}
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-xs sm:text-sm">데이터 매핑 (JSON)</Label>
|
|
|
|
|
<Textarea
|
|
|
|
|
value={formData.data_mapping}
|
|
|
|
|
onChange={(e) => setFormData({ ...formData, data_mapping: e.target.value })}
|
|
|
|
|
placeholder='{"source_field": "target_field"}'
|
|
|
|
|
className="min-h-[80px] font-mono text-xs sm:text-sm"
|
|
|
|
|
/>
|
|
|
|
|
<p className="mt-1 text-[10px] text-muted-foreground">
|
|
|
|
|
소스 화면의 필드를 타겟 화면의 필드로 매핑합니다
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 조건식 */}
|
|
|
|
|
<div>
|
|
|
|
|
<Label className="text-xs sm:text-sm">실행 조건 (선택)</Label>
|
|
|
|
|
<Input
|
|
|
|
|
value={formData.condition_expression}
|
|
|
|
|
onChange={(e) => setFormData({ ...formData, condition_expression: e.target.value })}
|
|
|
|
|
placeholder="예: data.status === 'active'"
|
|
|
|
|
className="h-8 font-mono text-xs sm:h-10 sm:text-sm"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<DialogFooter className="gap-2 sm:gap-0">
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={() => setIsModalOpen(false)}
|
|
|
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
|
|
|
>
|
|
|
|
|
취소
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
onClick={handleSave}
|
|
|
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
|
|
|
>
|
|
|
|
|
{selectedFlow ? "수정" : "추가"}
|
|
|
|
|
</Button>
|
|
|
|
|
</DialogFooter>
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-01-05 18:18:26 +09:00
|
|
|
|
2026-01-08 14:24:33 +09:00
|
|
|
|
|
|
|
|
|
2026-01-09 17:03:00 +09:00
|
|
|
|
2026-01-13 13:28:50 +09:00
|
|
|
|
|
|
|
|
|
2026-01-14 16:09:00 +09:00
|
|
|
|
2026-01-16 14:48:15 +09:00
|
|
|
|