refactor: Remove debug logs and optimize toast animations
- Removed debug console logs from the UPSERT process in the DataService to clean up the code. - Disabled animations for Sonner toast notifications to enhance performance and user experience. - Simplified the alert and dialog components by removing unnecessary animation classes, ensuring a smoother transition. - Updated the SelectedItemsDetailInputComponent to load all related table data in edit mode, improving data management and consistency.
This commit is contained in:
parent
423ef6231a
commit
d7f900d8ae
|
|
@ -688,7 +688,7 @@ router.post(
|
|||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const { tableName, parentKeys, records } = req.body;
|
||||
const { tableName, parentKeys, records, deleteOrphans = true } = req.body;
|
||||
|
||||
// 입력값 검증
|
||||
if (!tableName || !parentKeys || !records || !Array.isArray(records)) {
|
||||
|
|
@ -722,7 +722,8 @@ router.post(
|
|||
parentKeys,
|
||||
records,
|
||||
req.user?.companyCode,
|
||||
req.user?.userId
|
||||
req.user?.userId,
|
||||
deleteOrphans
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
|
|
|
|||
|
|
@ -1354,7 +1354,8 @@ class DataService {
|
|||
parentKeys: Record<string, any>,
|
||||
records: Array<Record<string, any>>,
|
||||
userCompany?: string,
|
||||
userId?: string
|
||||
userId?: string,
|
||||
deleteOrphans: boolean = true
|
||||
): Promise<
|
||||
ServiceResponse<{ inserted: number; updated: number; deleted: number }>
|
||||
> {
|
||||
|
|
@ -1422,11 +1423,6 @@ class DataService {
|
|||
const existingIds = new Set(existingRecords.rows.map((r: any) => r[pkColumn]));
|
||||
const processedIds = new Set<string>(); // UPDATE 처리된 id 추적
|
||||
|
||||
// DEBUG: 수신된 레코드와 기존 레코드 id 확인
|
||||
console.log(`🔑 [UPSERT DEBUG] pkColumn: ${pkColumn}`);
|
||||
console.log(`🔑 [UPSERT DEBUG] existingIds:`, Array.from(existingIds));
|
||||
console.log(`🔑 [UPSERT DEBUG] records received:`, records.map((r: any) => ({ id: r[pkColumn], keys: Object.keys(r) })));
|
||||
|
||||
for (const newRecord of records) {
|
||||
// 날짜 필드 정규화
|
||||
const normalizedRecord: Record<string, any> = {};
|
||||
|
|
|
|||
|
|
@ -289,6 +289,20 @@ select {
|
|||
}
|
||||
}
|
||||
|
||||
/* ===== Sonner 토스트 애니메이션 완전 제거 ===== */
|
||||
[data-sonner-toaster] [data-sonner-toast] {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
opacity: 1 !important;
|
||||
transform: none !important;
|
||||
}
|
||||
[data-sonner-toaster] [data-sonner-toast][data-mounted="true"] {
|
||||
animation: none !important;
|
||||
}
|
||||
[data-sonner-toaster] [data-sonner-toast][data-removed="true"] {
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
/* ===== Print Styles ===== */
|
||||
@media print {
|
||||
* {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Database, Cog } from "lucide-react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -6389,19 +6390,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
{activeLayerId > 1 && (
|
||||
<div className="sticky top-0 z-30 flex items-center justify-center gap-2 border-b bg-amber-50 px-4 py-1.5 backdrop-blur-sm dark:bg-amber-950/30">
|
||||
<div className="h-2 w-2 rounded-full bg-amber-500" />
|
||||
<span className="text-xs font-medium">
|
||||
레이어 {activeLayerId} 편집 중
|
||||
{layerRegions[activeLayerId] && (
|
||||
<span className="ml-2 text-amber-600">
|
||||
(캔버스: {layerRegions[activeLayerId].width} x {layerRegions[activeLayerId].height}px)
|
||||
</span>
|
||||
)}
|
||||
{!layerRegions[activeLayerId] && (
|
||||
<span className="ml-2 text-red-500">
|
||||
(조건부 영역 미설정 - 기본 레이어에서 영역을 먼저 배치하세요)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-xs font-medium">레이어 {activeLayerId} 편집 중</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ const AlertDialogOverlay = React.forwardRef<
|
|||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-[999] bg-black/80",
|
||||
"fixed inset-0 z-[999] bg-black/80",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -36,7 +36,7 @@ const AlertDialogContent = React.forwardRef<
|
|||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed top-[50%] left-[50%] z-[1000] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg",
|
||||
"bg-background fixed top-[50%] left-[50%] z-[1000] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef<
|
|||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-[999] bg-black/60",
|
||||
"fixed inset-0 z-[999] bg-black/60",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
|
|||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-[1000] flex w-full max-w-lg translate-x-[-50%] translate-y-[-50%] flex-col gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg",
|
||||
"bg-background fixed top-[50%] left-[50%] z-[1000] flex w-full max-w-lg translate-x-[-50%] translate-y-[-50%] flex-col gap-4 border p-6 shadow-lg sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ const DropdownMenuSubContent = React.forwardRef<
|
|||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[99999] min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
"bg-popover text-popover-foreground z-[99999] min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -63,7 +63,7 @@ const DropdownMenuContent = React.forwardRef<
|
|||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[99999] min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md",
|
||||
"bg-popover text-popover-foreground z-[99999] min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ const PopoverContent = React.forwardRef<
|
|||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[2000] w-72 rounded-md border p-4 shadow-md outline-none",
|
||||
"bg-popover text-popover-foreground z-[2000] w-72 rounded-md border p-4 shadow-md outline-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -16,16 +16,14 @@ const SheetClose = SheetPrimitive.Close;
|
|||
const SheetPortal = SheetPrimitive.Portal;
|
||||
|
||||
const sheetVariants = cva(
|
||||
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
"fixed z-50 gap-4 bg-background p-6 shadow-lg",
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||
bottom:
|
||||
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||
right:
|
||||
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||
top: "inset-x-0 top-0 border-b",
|
||||
bottom: "inset-x-0 bottom-0 border-t",
|
||||
left: "inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||
right: "inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
|
@ -60,7 +58,7 @@ const SheetOverlay = React.forwardRef<
|
|||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn(
|
||||
"bg-background/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 backdrop-blur-sm",
|
||||
"bg-background/80 fixed inset-0 z-50 backdrop-blur-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -236,7 +236,8 @@ export const dataApi = {
|
|||
upsertGroupedRecords: async (
|
||||
tableName: string,
|
||||
parentKeys: Record<string, any>,
|
||||
records: Array<Record<string, any>>
|
||||
records: Array<Record<string, any>>,
|
||||
options?: { deleteOrphans?: boolean }
|
||||
): Promise<{ success: boolean; inserted?: number; updated?: number; deleted?: number; message?: string; error?: string }> => {
|
||||
try {
|
||||
console.log("📡 [dataApi.upsertGroupedRecords] 요청 데이터:", {
|
||||
|
|
@ -251,6 +252,7 @@ export const dataApi = {
|
|||
tableName,
|
||||
parentKeys,
|
||||
records,
|
||||
deleteOrphans: options?.deleteOrphans ?? true, // 기본값: true (기존 동작 유지)
|
||||
};
|
||||
console.log("📦 [dataApi.upsertGroupedRecords] 요청 본문 (JSON):", JSON.stringify(requestBody, null, 2));
|
||||
|
||||
|
|
|
|||
|
|
@ -223,33 +223,34 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
|||
const additionalFields = componentConfig.additionalFields || [];
|
||||
const firstRecord = dataArray[0];
|
||||
|
||||
// 수정 모드: 다른 sourceTable의 데이터도 추가 로드 (예: customer_item_mapping)
|
||||
let mappingData: Record<string, any> | null = null;
|
||||
|
||||
// URL의 tableName = 이미 데이터가 로드된 테이블. 그 외 sourceTable은 추가 조회 필요
|
||||
// 수정 모드: 모든 관련 테이블의 데이터를 API로 전체 로드
|
||||
// sourceData는 클릭한 1개 레코드만 포함할 수 있으므로, API로 전체를 다시 가져옴
|
||||
const editTableName = new URLSearchParams(window.location.search).get("tableName");
|
||||
const otherTables = groups
|
||||
.filter((g) => g.sourceTable && g.sourceTable !== editTableName)
|
||||
.map((g) => g.sourceTable!)
|
||||
.filter((v, i, a) => a.indexOf(v) === i); // 중복 제거
|
||||
const allTableData: Record<string, Record<string, any>[]> = {};
|
||||
|
||||
if (otherTables.length > 0 && firstRecord.customer_id && firstRecord.item_id) {
|
||||
if (firstRecord.customer_id && firstRecord.item_id) {
|
||||
try {
|
||||
const { dataApi } = await import("@/lib/api/data");
|
||||
for (const otherTable of otherTables) {
|
||||
// getTableData 반환: { data: any[], total, page, size } (success 필드 없음)
|
||||
const response = await dataApi.getTableData(otherTable, {
|
||||
// 모든 sourceTable의 데이터를 API로 전체 로드 (중복 테이블 제거)
|
||||
const allTables = groups
|
||||
.map((g) => g.sourceTable || editTableName)
|
||||
.filter((v, i, a) => v && a.indexOf(v) === i) as string[];
|
||||
|
||||
for (const table of allTables) {
|
||||
const response = await dataApi.getTableData(table, {
|
||||
filters: {
|
||||
customer_id: firstRecord.customer_id,
|
||||
item_id: firstRecord.item_id,
|
||||
},
|
||||
sortBy: "created_date",
|
||||
sortOrder: "desc",
|
||||
});
|
||||
if (response.data && response.data.length > 0) {
|
||||
mappingData = response.data[0];
|
||||
allTableData[table] = response.data;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("❌ 매핑 데이터 로드 실패:", err);
|
||||
console.error("❌ 편집 데이터 전체 로드 실패:", err);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -263,41 +264,17 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
|||
return;
|
||||
}
|
||||
|
||||
// 이 그룹의 sourceTable에 따라 데이터 소스 결정
|
||||
const editTableName = new URLSearchParams(window.location.search).get("tableName");
|
||||
const isOtherTable = group.sourceTable && group.sourceTable !== editTableName;
|
||||
// 이 그룹의 sourceTable 결정 → API에서 가져온 전체 데이터 사용
|
||||
const groupTable = group.sourceTable || editTableName || "";
|
||||
// 현재 테이블만 sourceData fallback 허용 (다른 테이블은 빈 배열 → id 크로스오염 방지)
|
||||
const isCurrentTable = !group.sourceTable || group.sourceTable === editTableName;
|
||||
const groupDataList = allTableData[groupTable] || (isCurrentTable ? dataArray : []);
|
||||
|
||||
if (isOtherTable && mappingData) {
|
||||
// 다른 테이블 그룹 (예: customer_item_mapping) → mappingData에서 로드
|
||||
const entryData: Record<string, any> = {};
|
||||
groupFields.forEach((field: any) => {
|
||||
let fieldValue = mappingData![field.name];
|
||||
|
||||
// autoFillFrom 로직
|
||||
if ((fieldValue === undefined || fieldValue === null) && field.autoFillFrom) {
|
||||
fieldValue = firstRecord[field.autoFillFrom] || firstRecord.item_id;
|
||||
}
|
||||
|
||||
if (fieldValue !== undefined && fieldValue !== null) {
|
||||
entryData[field.name] = fieldValue;
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(entryData).length > 0) {
|
||||
mainFieldGroups[group.id] = [{
|
||||
id: `${group.id}_entry_1`,
|
||||
// DB 레코드의 고유 id(UUID PK) 보존 → 수정 시 이 id로 UPDATE
|
||||
_dbRecordId: mappingData!.id || null,
|
||||
...entryData,
|
||||
}];
|
||||
} else {
|
||||
mainFieldGroups[group.id] = [];
|
||||
}
|
||||
} else {
|
||||
// 현재 테이블 그룹 (예: customer_item_prices) → dataArray에서 로드
|
||||
{
|
||||
// 모든 테이블 그룹: API에서 가져온 전체 레코드를 entry로 변환
|
||||
const entriesMap = new Map<string, GroupEntry>();
|
||||
|
||||
dataArray.forEach((record) => {
|
||||
groupDataList.forEach((record) => {
|
||||
const entryData: Record<string, any> = {};
|
||||
|
||||
groupFields.forEach((field: any) => {
|
||||
|
|
@ -355,8 +332,6 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
|||
|
||||
const entryKey = JSON.stringify(entryData);
|
||||
if (!entriesMap.has(entryKey)) {
|
||||
// DEBUG: record.id 확인 (추후 삭제)
|
||||
console.log("🔑 [LOAD] record.id:", record.id, "record keys:", Object.keys(record));
|
||||
entriesMap.set(entryKey, {
|
||||
id: `${group.id}_entry_${entriesMap.size + 1}`,
|
||||
// DB 레코드의 고유 id(UUID PK) 보존 → 수정 시 이 id로 UPDATE
|
||||
|
|
@ -678,37 +653,36 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
|||
const itemParentKeys = { ...parentKeys, item_id: itemId };
|
||||
|
||||
// === Step 1: 메인 테이블(customer_item_mapping) 저장 ===
|
||||
const mappingRecord: Record<string, any> = {};
|
||||
// 여러 개의 매핑 레코드 지원 (거래처 품번/품명이 다중일 수 있음)
|
||||
const mappingRecords: Record<string, any>[] = [];
|
||||
mainGroups.forEach((group) => {
|
||||
const entries = item.fieldGroups[group.id] || [];
|
||||
const groupFields = additionalFields.filter((f) => f.groupId === group.id);
|
||||
|
||||
if (entries.length > 0) {
|
||||
entries.forEach((entry) => {
|
||||
const record: Record<string, any> = {};
|
||||
groupFields.forEach((field) => {
|
||||
const val = entries[0][field.name];
|
||||
// 사용자가 실제 입력한 값만 포함 (빈 문자열, null 제외)
|
||||
const val = entry[field.name];
|
||||
if (val !== undefined && val !== null && val !== "") {
|
||||
mappingRecord[field.name] = val;
|
||||
record[field.name] = val;
|
||||
}
|
||||
});
|
||||
// 기존 DB 레코드의 고유 id(PK)가 있으면 포함 → 백엔드에서 이 id로 UPDATE
|
||||
if (entries[0]._dbRecordId) {
|
||||
mappingRecord.id = entries[0]._dbRecordId;
|
||||
if (entry._dbRecordId) {
|
||||
record.id = entry._dbRecordId;
|
||||
}
|
||||
}
|
||||
|
||||
// autoFillFrom 필드 처리 (item_id 등)
|
||||
// 단, item_id는 이미 정확한 itemId 변수를 사용 (autoFillFrom:"id"가 수정 모드에서 오작동 방지)
|
||||
groupFields.forEach((field) => {
|
||||
if (field.name === "item_id") {
|
||||
// item_id는 위에서 계산된 정확한 itemId 사용
|
||||
mappingRecord.item_id = itemId;
|
||||
} else if (field.autoFillFrom && item.originalData) {
|
||||
const value = item.originalData[field.autoFillFrom];
|
||||
if (value !== undefined && value !== null) {
|
||||
mappingRecord[field.name] = value;
|
||||
// item_id는 정확한 itemId 변수 사용 (autoFillFrom:"id" 오작동 방지)
|
||||
record.item_id = itemId;
|
||||
// 나머지 autoFillFrom 필드 처리
|
||||
groupFields.forEach((field) => {
|
||||
if (field.name !== "item_id" && field.autoFillFrom && item.originalData) {
|
||||
const value = item.originalData[field.autoFillFrom];
|
||||
if (value !== undefined && value !== null && !record[field.name]) {
|
||||
record[field.name] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
mappingRecords.push(record);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -716,7 +690,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
|||
const mappingResult = await dataApi.upsertGroupedRecords(
|
||||
mainTable,
|
||||
itemParentKeys,
|
||||
[mappingRecord],
|
||||
mappingRecords,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(`❌ ${mainTable} 저장 실패:`, err);
|
||||
|
|
@ -757,8 +731,6 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
|||
if (entry._dbRecordId) {
|
||||
priceRecord.id = entry._dbRecordId;
|
||||
}
|
||||
// DEBUG: id 전달 확인용 (추후 삭제)
|
||||
console.log("🔑 [SAVE] entry._dbRecordId:", entry._dbRecordId, "→ priceRecord.id:", priceRecord.id, "entry keys:", Object.keys(entry));
|
||||
priceRecords.push(priceRecord);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue