402 lines
20 KiB
TypeScript
402 lines
20 KiB
TypeScript
|
|
import React, { useState, useEffect } from 'react';
|
||
|
|
|
||
|
|
interface CustomPath {
|
||
|
|
ge_id: string;
|
||
|
|
lang: string;
|
||
|
|
custom_path: string;
|
||
|
|
created_at?: string;
|
||
|
|
updated_at?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface CustomPathManagerModalProps {
|
||
|
|
isOpen: boolean;
|
||
|
|
onClose: () => void;
|
||
|
|
onPathUpdated?: () => void; // Callback khi có thay đổi
|
||
|
|
}
|
||
|
|
|
||
|
|
const CustomPathManagerModal: React.FC<CustomPathManagerModalProps> = ({ isOpen, onClose, onPathUpdated }) => {
|
||
|
|
const [searchTerm, setSearchTerm] = useState('');
|
||
|
|
const [allPaths, setAllPaths] = useState<CustomPath[]>([]);
|
||
|
|
const [filteredPaths, setFilteredPaths] = useState<CustomPath[]>([]);
|
||
|
|
const [visibleCount, setVisibleCount] = useState(10);
|
||
|
|
const [isLoading, setIsLoading] = useState(false);
|
||
|
|
const [error, setError] = useState<string | null>(null);
|
||
|
|
const [editingPath, setEditingPath] = useState<CustomPath | null>(null);
|
||
|
|
const [editForm, setEditForm] = useState({ ge_id: '', lang: '', custom_path: '' });
|
||
|
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||
|
|
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (isOpen) {
|
||
|
|
document.body.style.overflow = 'hidden';
|
||
|
|
loadPaths();
|
||
|
|
} else {
|
||
|
|
document.body.style.overflow = 'auto';
|
||
|
|
}
|
||
|
|
return () => {
|
||
|
|
document.body.style.overflow = 'auto';
|
||
|
|
};
|
||
|
|
}, [isOpen]);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
// Filter paths when search term changes
|
||
|
|
if (searchTerm.trim() === '') {
|
||
|
|
setFilteredPaths(allPaths.slice(0, visibleCount));
|
||
|
|
} else {
|
||
|
|
const filtered = allPaths
|
||
|
|
.filter(path =>
|
||
|
|
path.ge_id.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
|
|
path.lang.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
|
|
path.custom_path.toLowerCase().includes(searchTerm.toLowerCase())
|
||
|
|
)
|
||
|
|
.slice(0, visibleCount);
|
||
|
|
setFilteredPaths(filtered);
|
||
|
|
}
|
||
|
|
}, [searchTerm, allPaths, visibleCount]);
|
||
|
|
|
||
|
|
const loadPaths = async () => {
|
||
|
|
setIsLoading(true);
|
||
|
|
setError(null);
|
||
|
|
try {
|
||
|
|
const response = await fetch('/api/custom-paths');
|
||
|
|
const data = await response.json();
|
||
|
|
if (data.success && Array.isArray(data.custom_paths)) {
|
||
|
|
setAllPaths(data.custom_paths);
|
||
|
|
setFilteredPaths(data.custom_paths.slice(0, 10));
|
||
|
|
} else {
|
||
|
|
setError('Không thể tải danh sách custom paths');
|
||
|
|
}
|
||
|
|
} catch (err) {
|
||
|
|
console.error('Error loading custom paths:', err);
|
||
|
|
setError('Lỗi khi tải danh sách custom paths');
|
||
|
|
} finally {
|
||
|
|
setIsLoading(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
if (!isOpen) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleSearch = () => {
|
||
|
|
setVisibleCount(10);
|
||
|
|
const filtered = allPaths
|
||
|
|
.filter(path =>
|
||
|
|
path.ge_id.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
|
|
path.lang.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
|
|
path.custom_path.toLowerCase().includes(searchTerm.toLowerCase())
|
||
|
|
)
|
||
|
|
.slice(0, 10);
|
||
|
|
setFilteredPaths(filtered);
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||
|
|
if (e.key === 'Enter') {
|
||
|
|
e.preventDefault();
|
||
|
|
handleSearch();
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleLoadMore = () => {
|
||
|
|
const newCount = visibleCount + 10;
|
||
|
|
setVisibleCount(newCount);
|
||
|
|
|
||
|
|
if (searchTerm.trim() === '') {
|
||
|
|
setFilteredPaths(allPaths.slice(0, newCount));
|
||
|
|
} else {
|
||
|
|
const filtered = allPaths
|
||
|
|
.filter(path =>
|
||
|
|
path.ge_id.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
|
|
path.lang.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
|
|
path.custom_path.toLowerCase().includes(searchTerm.toLowerCase())
|
||
|
|
)
|
||
|
|
.slice(0, newCount);
|
||
|
|
setFilteredPaths(filtered);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleEdit = (path: CustomPath) => {
|
||
|
|
setEditingPath(path);
|
||
|
|
setEditForm({
|
||
|
|
ge_id: path.ge_id,
|
||
|
|
lang: path.lang,
|
||
|
|
custom_path: path.custom_path
|
||
|
|
});
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleCancelEdit = () => {
|
||
|
|
setEditingPath(null);
|
||
|
|
setEditForm({ ge_id: '', lang: '', custom_path: '' });
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleSaveEdit = async () => {
|
||
|
|
if (!editForm.ge_id || !editForm.lang || !editForm.custom_path) {
|
||
|
|
setError('Vui lòng điền đầy đủ thông tin');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
setIsLoading(true);
|
||
|
|
setError(null);
|
||
|
|
try {
|
||
|
|
// Delete old record if ge_id changed
|
||
|
|
if (editingPath && editingPath.ge_id !== editForm.ge_id) {
|
||
|
|
await fetch(`/api/custom-paths/${editingPath.ge_id}`, {
|
||
|
|
method: 'DELETE'
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// Create/update new record
|
||
|
|
const response = await fetch('/api/custom-paths', {
|
||
|
|
method: 'POST',
|
||
|
|
headers: { 'Content-Type': 'application/json' },
|
||
|
|
body: JSON.stringify(editForm)
|
||
|
|
});
|
||
|
|
|
||
|
|
const data = await response.json();
|
||
|
|
if (data.success) {
|
||
|
|
await loadPaths();
|
||
|
|
handleCancelEdit();
|
||
|
|
if (onPathUpdated) onPathUpdated();
|
||
|
|
} else {
|
||
|
|
setError(data.message || 'Không thể lưu custom path');
|
||
|
|
}
|
||
|
|
} catch (err) {
|
||
|
|
console.error('Error saving custom path:', err);
|
||
|
|
setError('Lỗi khi lưu custom path');
|
||
|
|
} finally {
|
||
|
|
setIsLoading(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleDeleteClick = (geId: string) => {
|
||
|
|
setDeleteTarget(geId);
|
||
|
|
setShowDeleteConfirm(true);
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleConfirmDelete = async () => {
|
||
|
|
if (!deleteTarget) return;
|
||
|
|
|
||
|
|
setIsLoading(true);
|
||
|
|
setError(null);
|
||
|
|
try {
|
||
|
|
const response = await fetch(`/api/custom-paths/${deleteTarget}`, {
|
||
|
|
method: 'DELETE'
|
||
|
|
});
|
||
|
|
|
||
|
|
const data = await response.json();
|
||
|
|
if (data.success) {
|
||
|
|
await loadPaths();
|
||
|
|
setShowDeleteConfirm(false);
|
||
|
|
setDeleteTarget(null);
|
||
|
|
if (onPathUpdated) onPathUpdated();
|
||
|
|
} else {
|
||
|
|
setError(data.message || 'Không thể xóa custom path');
|
||
|
|
}
|
||
|
|
} catch (err) {
|
||
|
|
console.error('Error deleting custom path:', err);
|
||
|
|
setError('Lỗi khi xóa custom path');
|
||
|
|
} finally {
|
||
|
|
setIsLoading(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleCancelDelete = () => {
|
||
|
|
setShowDeleteConfirm(false);
|
||
|
|
setDeleteTarget(null);
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="fixed inset-0 bg-black/70 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||
|
|
<div className="bg-slate-800 rounded-2xl shadow-2xl w-full max-w-4xl max-h-[90vh] flex flex-col border border-slate-700">
|
||
|
|
{/* Header */}
|
||
|
|
<div className="flex justify-between items-center p-6 border-b border-slate-700">
|
||
|
|
<h2 className="text-2xl font-bold text-white">Quản lý Custom Paths</h2>
|
||
|
|
<button
|
||
|
|
onClick={onClose}
|
||
|
|
className="text-slate-400 hover:text-white transition-colors p-2 hover:bg-slate-700 rounded-lg"
|
||
|
|
>
|
||
|
|
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="currentColor">
|
||
|
|
<path fillRule="evenodd" d="M5.47 5.47a.75.75 0 0 1 1.06 0L12 10.94l5.47-5.47a.75.75 0 1 1 1.06 1.06L13.06 12l5.47 5.47a.75.75 0 1 1-1.06 1.06L12 13.06l-5.47 5.47a.75.75 0 0 1-1.06-1.06L10.94 12 5.47 6.53a.75.75 0 0 1 0-1.06Z" clipRule="evenodd" />
|
||
|
|
</svg>
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Search Bar */}
|
||
|
|
<div className="p-6 border-b border-slate-700">
|
||
|
|
<div className="flex gap-3">
|
||
|
|
<input
|
||
|
|
type="text"
|
||
|
|
value={searchTerm}
|
||
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||
|
|
onKeyDown={handleInputKeyDown}
|
||
|
|
placeholder="Tìm kiếm GE ID, Lang, hoặc Path..."
|
||
|
|
className="flex-1 bg-slate-900/50 border border-slate-600 text-white text-sm rounded-lg focus:ring-indigo-500 focus:border-indigo-500 px-4 py-2.5"
|
||
|
|
/>
|
||
|
|
<button
|
||
|
|
onClick={handleSearch}
|
||
|
|
className="px-6 py-2.5 bg-indigo-600 hover:bg-indigo-700 text-white font-medium rounded-lg transition-colors"
|
||
|
|
>
|
||
|
|
Tìm kiếm
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Error Message */}
|
||
|
|
{error && (
|
||
|
|
<div className="mx-6 mt-4 p-4 bg-red-900/20 border border-red-500/50 rounded-lg">
|
||
|
|
<p className="text-red-400 text-sm">{error}</p>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Content */}
|
||
|
|
<div className="flex-1 overflow-y-auto p-6">
|
||
|
|
{isLoading && filteredPaths.length === 0 ? (
|
||
|
|
<div className="flex justify-center items-center py-12">
|
||
|
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-500"></div>
|
||
|
|
</div>
|
||
|
|
) : filteredPaths.length === 0 ? (
|
||
|
|
<div className="text-center py-12">
|
||
|
|
<p className="text-slate-400">Không tìm thấy custom path nào</p>
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<div className="space-y-3">
|
||
|
|
{filteredPaths.map((path) => (
|
||
|
|
<div
|
||
|
|
key={path.ge_id}
|
||
|
|
className="bg-slate-900/50 border border-slate-700 rounded-lg p-4 hover:border-slate-600 transition-colors"
|
||
|
|
>
|
||
|
|
{editingPath?.ge_id === path.ge_id ? (
|
||
|
|
/* Edit Mode */
|
||
|
|
<div className="space-y-3">
|
||
|
|
<div className="grid grid-cols-3 gap-3">
|
||
|
|
<input
|
||
|
|
type="text"
|
||
|
|
value={editForm.ge_id}
|
||
|
|
onChange={(e) => setEditForm({ ...editForm, ge_id: e.target.value })}
|
||
|
|
placeholder="GE ID"
|
||
|
|
className="bg-slate-800 border border-slate-600 text-white text-sm rounded-lg px-3 py-2"
|
||
|
|
/>
|
||
|
|
<input
|
||
|
|
type="text"
|
||
|
|
value={editForm.lang}
|
||
|
|
onChange={(e) => setEditForm({ ...editForm, lang: e.target.value.toUpperCase() })}
|
||
|
|
placeholder="Lang"
|
||
|
|
className="bg-slate-800 border border-slate-600 text-white text-sm rounded-lg px-3 py-2"
|
||
|
|
/>
|
||
|
|
<input
|
||
|
|
type="text"
|
||
|
|
value={editForm.custom_path}
|
||
|
|
onChange={(e) => setEditForm({ ...editForm, custom_path: e.target.value })}
|
||
|
|
placeholder="Custom Path"
|
||
|
|
className="bg-slate-800 border border-slate-600 text-white text-sm rounded-lg px-3 py-2"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div className="flex gap-2">
|
||
|
|
<button
|
||
|
|
onClick={handleSaveEdit}
|
||
|
|
disabled={isLoading}
|
||
|
|
className="px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white text-sm rounded-lg disabled:opacity-50"
|
||
|
|
>
|
||
|
|
Lưu
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
onClick={handleCancelEdit}
|
||
|
|
className="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white text-sm rounded-lg"
|
||
|
|
>
|
||
|
|
Hủy
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
/* View Mode */
|
||
|
|
<div className="flex items-center justify-between">
|
||
|
|
<div className="flex-1 space-y-1">
|
||
|
|
<div className="flex items-center gap-3">
|
||
|
|
<span className="text-white font-semibold">{path.ge_id}</span>
|
||
|
|
<span className="px-2 py-0.5 bg-indigo-600/20 text-indigo-300 text-xs rounded">
|
||
|
|
{path.lang}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
<p className="text-slate-300 text-sm font-mono">{path.custom_path}</p>
|
||
|
|
</div>
|
||
|
|
<div className="flex gap-2">
|
||
|
|
<button
|
||
|
|
onClick={() => handleEdit(path)}
|
||
|
|
className="p-2 text-indigo-400 hover:text-indigo-300 hover:bg-slate-800 rounded-lg transition-colors"
|
||
|
|
title="Sửa"
|
||
|
|
>
|
||
|
|
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
||
|
|
<path d="M21.731 2.269a2.625 2.625 0 0 0-3.712 0l-1.157 1.157 3.712 3.712 1.157-1.157a2.625 2.625 0 0 0 0-3.712ZM19.513 8.199l-3.712-3.712-8.4 8.4a5.25 5.25 0 0 0-1.32 2.214l-.8 2.685a.75.75 0 0 0 .933.933l2.685-.8a5.25 5.25 0 0 0 2.214-1.32l8.4-8.4Z" />
|
||
|
|
<path d="M5.25 5.25a3 3 0 0 0-3 3v10.5a3 3 0 0 0 3 3h10.5a3 3 0 0 0 3-3V13.5a.75.75 0 0 0-1.5 0v5.25a1.5 1.5 0 0 1-1.5 1.5H5.25a1.5 1.5 0 0 1-1.5-1.5V8.25a1.5 1.5 0 0 1 1.5-1.5h5.25a.75.75 0 0 0 0-1.5H5.25Z" />
|
||
|
|
</svg>
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
onClick={() => handleDeleteClick(path.ge_id)}
|
||
|
|
className="p-2 text-red-400 hover:text-red-300 hover:bg-slate-800 rounded-lg transition-colors"
|
||
|
|
title="Xóa"
|
||
|
|
>
|
||
|
|
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
||
|
|
<path fillRule="evenodd" d="M16.5 4.478v.227a48.816 48.816 0 0 1 3.878.512.75.75 0 1 1-.256 1.478l-.209-.035-1.005 13.07a3 3 0 0 1-2.991 2.77H8.084a3 3 0 0 1-2.991-2.77L4.087 6.66l-.209.035a.75.75 0 0 1-.256-1.478A48.567 48.567 0 0 1 7.5 4.705v-.227c0-1.564 1.213-2.9 2.816-2.951a52.662 52.662 0 0 1 3.369 0c1.603.051 2.815 1.387 2.815 2.951Zm-6.136-1.452a51.196 51.196 0 0 1 3.273 0C14.39 3.05 15 3.684 15 4.478v.113a49.488 49.488 0 0 0-6 0v-.113c0-.794.609-1.428 1.364-1.452Zm-.355 5.945a.75.75 0 1 0-1.5.058l.347 9a.75.75 0 1 0 1.499-.058l-.346-9Zm5.48.058a.75.75 0 1 0-1.498-.058l-.347 9a.75.75 0 0 0 1.5.058l.345-9Z" clipRule="evenodd" />
|
||
|
|
</svg>
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Load More Button */}
|
||
|
|
{filteredPaths.length < allPaths.length && (
|
||
|
|
<div className="mt-6 text-center">
|
||
|
|
<button
|
||
|
|
onClick={handleLoadMore}
|
||
|
|
className="px-6 py-2.5 bg-slate-700 hover:bg-slate-600 text-white font-medium rounded-lg transition-colors"
|
||
|
|
>
|
||
|
|
Tải thêm
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Footer Info */}
|
||
|
|
<div className="p-4 border-t border-slate-700 bg-slate-900/50">
|
||
|
|
<p className="text-slate-400 text-sm text-center">
|
||
|
|
Tổng số: <span className="text-white font-semibold">{allPaths.length}</span> custom paths
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Delete Confirmation Modal */}
|
||
|
|
{showDeleteConfirm && (
|
||
|
|
<div className="fixed inset-0 bg-black/70 backdrop-blur-sm flex items-center justify-center z-[60]">
|
||
|
|
<div className="bg-slate-800 rounded-xl shadow-2xl p-6 max-w-md w-full mx-4 border border-slate-700">
|
||
|
|
<h3 className="text-xl font-bold text-white mb-4">Xác nhận xóa</h3>
|
||
|
|
<p className="text-slate-300 mb-6">
|
||
|
|
Bạn có chắc chắn muốn xóa custom path cho <span className="text-white font-semibold">{deleteTarget}</span>?
|
||
|
|
</p>
|
||
|
|
<div className="flex gap-3">
|
||
|
|
<button
|
||
|
|
onClick={handleConfirmDelete}
|
||
|
|
disabled={isLoading}
|
||
|
|
className="flex-1 px-4 py-2.5 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg disabled:opacity-50"
|
||
|
|
>
|
||
|
|
Xóa
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
onClick={handleCancelDelete}
|
||
|
|
className="flex-1 px-4 py-2.5 bg-slate-700 hover:bg-slate-600 text-white font-medium rounded-lg"
|
||
|
|
>
|
||
|
|
Hủy
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
};
|
||
|
|
|
||
|
|
export default CustomPathManagerModal;
|