mirror of
https://github.com/nethunterzist/trendyol-analiz
synced 2026-07-01 01:17:04 +00:00
feat: Miller Columns kategori seçici + JSON tree tabanlı mimari
Ne yaptık: - Sahibinden.com tarzı Miller Columns kategori seçici (CategorySelector.jsx) - Trendyol API'den 3971 kategori ağacı çekildi (Playwright ile) - Backend: JSON tree tabanlı kategori endpoint'leri (/api/category-tree/*) - Backend: Rapor oluşturma artık DB kategorilerine bağımlı değil - Report tablosundaki category_id FK constraint kaldırıldı - Dockerfile'a trendyol_category_tree.json eklendi Neden yaptık: - DB'deki kategori tablosu boştu, Trendyol API ID'leri ile Excel ID'leri farklıydı - Playwright ile Trendyol'un kendi kategori ağacını çektik (3971 kategori, gerçek API ID'leri) - Miller Columns ile kullanıcı adım adım derinleşerek kategori seçebiliyor - Arama özelliği ile kelime bazlı kategori bulma da mümkün Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
336
admin-panel/src/components/CategorySelector.jsx
Normal file
336
admin-panel/src/components/CategorySelector.jsx
Normal file
@@ -0,0 +1,336 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { API_URL, fetchWithTimeout } from '../config/api'
|
||||
import { Search, ChevronRight, Loader2, X, Layers } from 'lucide-react'
|
||||
|
||||
/**
|
||||
* Miller Columns category selector (sahibinden.com style)
|
||||
* Shows cascading columns: click a category → its children appear in the next column
|
||||
*/
|
||||
function CategorySelector({ onSelect, disabled = false }) {
|
||||
const [allCategories, setAllCategories] = useState([])
|
||||
const [columns, setColumns] = useState([]) // [{parentId, items, selectedId}]
|
||||
const [breadcrumb, setBreadcrumb] = useState([]) // [{id, name}]
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [searchResults, setSearchResults] = useState([])
|
||||
const [searchMode, setSearchMode] = useState(false)
|
||||
const columnsRef = useRef(null)
|
||||
|
||||
// Load all categories once
|
||||
useEffect(() => {
|
||||
loadCategories()
|
||||
}, [])
|
||||
|
||||
const loadCategories = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetchWithTimeout(`${API_URL}/api/category-tree`)
|
||||
if (!res.ok) throw new Error('Failed to load categories')
|
||||
const data = await res.json()
|
||||
setAllCategories(data)
|
||||
|
||||
// Initialize with root categories
|
||||
const roots = data.filter(c => c.level === 0)
|
||||
setColumns([{ parentId: null, items: roots, selectedId: null }])
|
||||
} catch (err) {
|
||||
console.error('Category load error:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getChildren = (parentId) => {
|
||||
return allCategories.filter(c => c.parentId === parentId)
|
||||
}
|
||||
|
||||
const handleItemClick = (item, columnIndex) => {
|
||||
if (disabled) return
|
||||
|
||||
const children = getChildren(item.id)
|
||||
|
||||
// Update selection in current column
|
||||
const newColumns = columns.slice(0, columnIndex + 1)
|
||||
newColumns[columnIndex] = { ...newColumns[columnIndex], selectedId: item.id }
|
||||
|
||||
// Add children column if exists
|
||||
if (children.length > 0) {
|
||||
newColumns.push({ parentId: item.id, items: children, selectedId: null })
|
||||
}
|
||||
|
||||
setColumns(newColumns)
|
||||
|
||||
// Build breadcrumb
|
||||
const newBreadcrumb = []
|
||||
for (let i = 0; i <= columnIndex; i++) {
|
||||
const col = newColumns[i]
|
||||
if (col.selectedId) {
|
||||
const selected = col.items.find(c => c.id === col.selectedId)
|
||||
if (selected) newBreadcrumb.push({ id: selected.id, name: selected.name })
|
||||
}
|
||||
}
|
||||
setBreadcrumb(newBreadcrumb)
|
||||
|
||||
// Notify parent — always pass clicked category info
|
||||
if (onSelect) {
|
||||
onSelect({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
path: [...newBreadcrumb.map(b => b.name)].join(' > '),
|
||||
hasChildren: children.length > 0,
|
||||
isLeaf: children.length === 0,
|
||||
level: item.level,
|
||||
url: item.url
|
||||
})
|
||||
}
|
||||
|
||||
// Scroll to show new column
|
||||
setTimeout(() => {
|
||||
if (columnsRef.current) {
|
||||
columnsRef.current.scrollLeft = columnsRef.current.scrollWidth
|
||||
}
|
||||
}, 50)
|
||||
}
|
||||
|
||||
const handleBreadcrumbClick = (index) => {
|
||||
if (disabled) return
|
||||
|
||||
if (index === -1) {
|
||||
// Click on root — reset everything
|
||||
const roots = allCategories.filter(c => c.level === 0)
|
||||
setColumns([{ parentId: null, items: roots, selectedId: null }])
|
||||
setBreadcrumb([])
|
||||
setSearchMode(false)
|
||||
if (onSelect) onSelect(null)
|
||||
return
|
||||
}
|
||||
|
||||
const target = breadcrumb[index]
|
||||
const newColumns = columns.slice(0, index + 2)
|
||||
const newBreadcrumb = breadcrumb.slice(0, index + 1)
|
||||
setBreadcrumb(newBreadcrumb)
|
||||
setColumns(newColumns)
|
||||
}
|
||||
|
||||
// Search
|
||||
useEffect(() => {
|
||||
if (searchQuery.length < 2) {
|
||||
setSearchResults([])
|
||||
return
|
||||
}
|
||||
const q = searchQuery.toLowerCase()
|
||||
const results = allCategories.filter(c => c.name.toLowerCase().includes(q)).slice(0, 30)
|
||||
setSearchResults(results)
|
||||
}, [searchQuery, allCategories])
|
||||
|
||||
const handleSearchSelect = (item) => {
|
||||
if (disabled) return
|
||||
|
||||
// Build the full path and open all columns
|
||||
const pathParts = item.path.split(' > ')
|
||||
const newColumns = []
|
||||
const newBreadcrumb = []
|
||||
|
||||
// Start with roots
|
||||
const roots = allCategories.filter(c => c.level === 0)
|
||||
let currentItems = roots
|
||||
let selectedId = null
|
||||
|
||||
for (let i = 0; i < pathParts.length; i++) {
|
||||
const part = pathParts[i]
|
||||
const found = currentItems.find(c => c.name === part)
|
||||
|
||||
if (found) {
|
||||
selectedId = found.id
|
||||
newColumns.push({ parentId: found.parentId, items: currentItems, selectedId: found.id })
|
||||
newBreadcrumb.push({ id: found.id, name: found.name })
|
||||
|
||||
const children = getChildren(found.id)
|
||||
if (children.length > 0 && i < pathParts.length - 1) {
|
||||
currentItems = children
|
||||
} else if (children.length > 0) {
|
||||
// Last item has children — show them
|
||||
newColumns.push({ parentId: found.id, items: children, selectedId: null })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setColumns(newColumns)
|
||||
setBreadcrumb(newBreadcrumb)
|
||||
setSearchMode(false)
|
||||
setSearchQuery('')
|
||||
|
||||
if (onSelect) {
|
||||
const children = getChildren(item.id)
|
||||
onSelect({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
hasChildren: children.length > 0,
|
||||
isLeaf: children.length === 0,
|
||||
level: item.level,
|
||||
url: item.url
|
||||
})
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (columnsRef.current) {
|
||||
columnsRef.current.scrollLeft = columnsRef.current.scrollWidth
|
||||
}
|
||||
}, 50)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-12 flex items-center justify-center">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-orange-400 mr-2" />
|
||||
<span className="text-sm text-slate-400">Kategoriler yükleniyor...</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-5 py-4 border-b border-slate-100">
|
||||
<h2 className="text-base font-semibold text-slate-800 mb-1">Adım Adım Kategori Seç</h2>
|
||||
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center gap-1 text-sm flex-wrap min-h-[24px]">
|
||||
{breadcrumb.length > 0 ? (
|
||||
<>
|
||||
{breadcrumb.map((crumb, index) => (
|
||||
<span key={crumb.id} className="flex items-center gap-1">
|
||||
{index > 0 && <ChevronRight className="w-3.5 h-3.5 text-slate-300" />}
|
||||
{index < breadcrumb.length - 1 ? (
|
||||
<button
|
||||
onClick={() => handleBreadcrumbClick(index)}
|
||||
disabled={disabled}
|
||||
className="text-orange-500 hover:text-orange-600 hover:underline font-medium transition-colors"
|
||||
>
|
||||
{crumb.name}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-slate-700 font-medium">{crumb.name}</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-slate-400 text-xs">Bir kategori seçin</span>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Miller Columns */}
|
||||
{!searchMode && (
|
||||
<div
|
||||
ref={columnsRef}
|
||||
className="flex overflow-x-auto border-b border-slate-100"
|
||||
style={{ scrollBehavior: 'smooth' }}
|
||||
>
|
||||
{columns.map((column, colIndex) => (
|
||||
<div
|
||||
key={`${column.parentId}-${colIndex}`}
|
||||
className="min-w-[220px] max-w-[260px] flex-shrink-0 border-r border-slate-100 last:border-r-0"
|
||||
>
|
||||
<div className="max-h-[380px] overflow-y-auto">
|
||||
{column.items.map((item) => {
|
||||
const isSelected = column.selectedId === item.id
|
||||
const children = getChildren(item.id)
|
||||
const hasChildren = children.length > 0
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => handleItemClick(item, colIndex)}
|
||||
disabled={disabled}
|
||||
className={`w-full text-left px-4 py-2.5 text-sm flex items-center justify-between transition-colors ${
|
||||
isSelected
|
||||
? 'bg-orange-50 text-orange-700 font-medium'
|
||||
: 'text-slate-700 hover:bg-slate-50'
|
||||
} ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
>
|
||||
<span className="truncate pr-2">{item.name}</span>
|
||||
{hasChildren && (
|
||||
<ChevronRight className={`w-3.5 h-3.5 flex-shrink-0 ${
|
||||
isSelected ? 'text-orange-400' : 'text-slate-300'
|
||||
}`} />
|
||||
)}
|
||||
{!hasChildren && isSelected && (
|
||||
<Layers className="w-3.5 h-3.5 flex-shrink-0 text-orange-400" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search Results */}
|
||||
{searchMode && searchResults.length > 0 && (
|
||||
<div className="max-h-[380px] overflow-y-auto border-b border-slate-100">
|
||||
{searchResults.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => handleSearchSelect(item)}
|
||||
disabled={disabled}
|
||||
className="w-full text-left px-5 py-3 text-sm hover:bg-orange-50 transition-colors border-b border-slate-50 last:border-b-0"
|
||||
>
|
||||
<span className="font-medium text-slate-800">{item.name}</span>
|
||||
<span className="block text-xs text-slate-400 mt-0.5">{item.path}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{searchMode && searchQuery.length >= 2 && searchResults.length === 0 && (
|
||||
<div className="px-5 py-12 text-center text-sm text-slate-400 border-b border-slate-100">
|
||||
Sonuç bulunamadı
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Divider with "veya" */}
|
||||
<div className="flex items-center gap-4 px-5 py-3">
|
||||
<div className="flex-1 h-px bg-slate-200" />
|
||||
<span className="text-xs font-medium text-slate-400">veya</span>
|
||||
<div className="flex-1 h-px bg-slate-200" />
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="px-5 pb-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-300" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Kelime ile Arayarak Kategori Seç"
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value)
|
||||
setSearchMode(e.target.value.length >= 2)
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (searchQuery.length >= 2) setSearchMode(true)
|
||||
}}
|
||||
disabled={disabled}
|
||||
className="w-full pl-10 pr-9 py-2.5 bg-slate-50 border border-slate-200 rounded-lg text-sm focus:outline-none focus:border-orange-300 focus:ring-1 focus:ring-orange-300 transition-all placeholder:text-slate-400"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearchQuery('')
|
||||
setSearchMode(false)
|
||||
}}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-300 hover:text-slate-500"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CategorySelector
|
||||
@@ -1,40 +1,23 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { API_URL, fetchWithTimeout, TIMEOUT_CONFIG, calculateNextDelay } from '../config/api'
|
||||
import { Search, Loader2, ChevronRight, ArrowLeft, X, Check } from 'lucide-react'
|
||||
import { Loader2, Check } from 'lucide-react'
|
||||
import CategorySelector from './CategorySelector'
|
||||
|
||||
function ReportGeneration() {
|
||||
const [mainCategories, setMainCategories] = useState([])
|
||||
const [selectedCategory, setSelectedCategory] = useState(null)
|
||||
const [subCategories, setSubCategories] = useState([])
|
||||
const [selectedSubCategories, setSelectedSubCategories] = useState([])
|
||||
const [loadingSubCategories, setLoadingSubCategories] = useState(false)
|
||||
const [subCategorySearch, setSubCategorySearch] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [generating, setGenerating] = useState(false)
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [logs, setLogs] = useState([])
|
||||
const [showNameModal, setShowNameModal] = useState(false)
|
||||
const [reportName, setReportName] = useState('')
|
||||
const [reportData, setReportData] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [showCompletionModal, setShowCompletionModal] = useState(false)
|
||||
const [completionData, setCompletionData] = useState(null)
|
||||
const [subBreadcrumb, setSubBreadcrumb] = useState([])
|
||||
const [showSearch, setShowSearch] = useState(false)
|
||||
const pollTimeoutRef = useRef(null)
|
||||
const isMountedRef = useRef(true)
|
||||
const logsEndRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true
|
||||
fetchMainCategories()
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false
|
||||
if (pollTimeoutRef.current) {
|
||||
clearTimeout(pollTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
return () => { isMountedRef.current = false }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -43,78 +26,9 @@ function ReportGeneration() {
|
||||
}
|
||||
}, [logs])
|
||||
|
||||
const fetchMainCategories = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const response = await fetchWithTimeout(`${API_URL}/categories/main`)
|
||||
if (!response.ok) throw new Error('Failed to fetch categories')
|
||||
const data = await response.json()
|
||||
if (isMountedRef.current) {
|
||||
setMainCategories(data)
|
||||
}
|
||||
} catch (err) {
|
||||
if (isMountedRef.current) {
|
||||
setError(err.message)
|
||||
}
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fetchSubCategories = async (categoryId, resetSelection = true) => {
|
||||
setLoadingSubCategories(true)
|
||||
setSubCategories([])
|
||||
if (resetSelection) {
|
||||
setSelectedSubCategories([])
|
||||
setSubBreadcrumb([])
|
||||
}
|
||||
setSubCategorySearch('')
|
||||
setShowSearch(false)
|
||||
try {
|
||||
const response = await fetchWithTimeout(`${API_URL}/categories/${categoryId}/children`)
|
||||
if (!response.ok) throw new Error('Failed to fetch sub-categories')
|
||||
const data = await response.json()
|
||||
if (isMountedRef.current) {
|
||||
setSubCategories(data)
|
||||
}
|
||||
} catch (err) {
|
||||
if (isMountedRef.current) {
|
||||
console.error('Sub-category fetch error:', err)
|
||||
}
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setLoadingSubCategories(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleDrillDown = (subCat) => {
|
||||
if (generating || subCat.children_count === 0) return
|
||||
setSubBreadcrumb(prev => [...prev, subCat])
|
||||
fetchSubCategories(subCat.id, false)
|
||||
}
|
||||
|
||||
const handleBreadcrumbBack = (index) => {
|
||||
if (index === -1) {
|
||||
setSubBreadcrumb([])
|
||||
fetchSubCategories(selectedCategory.id, false)
|
||||
} else {
|
||||
const target = subBreadcrumb[index]
|
||||
setSubBreadcrumb(subBreadcrumb.slice(0, index + 1))
|
||||
fetchSubCategories(target.id, false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSubCategory = (subCat) => {
|
||||
const handleCategorySelect = (category) => {
|
||||
if (generating) return
|
||||
setSelectedSubCategories(prev => {
|
||||
const exists = prev.some(c => c.id === subCat.id)
|
||||
if (exists) return prev.filter(c => c.id !== subCat.id)
|
||||
return [...prev, subCat]
|
||||
})
|
||||
setSelectedCategory(category)
|
||||
}
|
||||
|
||||
const addLog = (message, type = 'info') => {
|
||||
@@ -143,27 +57,14 @@ function ReportGeneration() {
|
||||
setLogs([])
|
||||
|
||||
try {
|
||||
const requestBody = {
|
||||
name: reportName,
|
||||
category_id: selectedCategory.id
|
||||
}
|
||||
|
||||
// Tüm alt kategoriler seçiliyse subcategory_ids gönderme — backend zaten tümünü tarar
|
||||
const allSelected = selectedSubCategories.length > 0 &&
|
||||
selectedSubCategories.length === subCategories.length
|
||||
if (selectedSubCategories.length > 0 && !allSelected) {
|
||||
requestBody.subcategory_ids = selectedSubCategories.map(cat => cat.id)
|
||||
}
|
||||
|
||||
console.log('🔍 Frontend - Rapor oluşturuluyor:')
|
||||
console.log(' - Rapor adı:', requestBody.name)
|
||||
console.log(' - Kategori ID:', requestBody.category_id)
|
||||
console.log(' - Alt kategori IDs:', requestBody.subcategory_ids)
|
||||
console.log(' - Rapor adı:', reportName)
|
||||
console.log(' - Kategori ID:', selectedCategory.id)
|
||||
console.log(' - Kategori path:', selectedCategory.path)
|
||||
|
||||
const params = new URLSearchParams({
|
||||
name: requestBody.name,
|
||||
category_id: requestBody.category_id,
|
||||
...(requestBody.subcategory_ids && { subcategory_ids: JSON.stringify(requestBody.subcategory_ids) })
|
||||
name: reportName,
|
||||
category_id: selectedCategory.id
|
||||
})
|
||||
|
||||
const sseUrl = `${API_URL}/api/reports/create?${params}`
|
||||
@@ -234,301 +135,18 @@ function ReportGeneration() {
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-slate-400 flex items-center gap-2">
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
Yükleniyor...
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<p className="text-red-700">Hata: {error}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const filteredSubCategories = subCategories.filter((subCat) =>
|
||||
subCat.name.toLowerCase().includes(subCategorySearch.toLowerCase())
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Main Category Selection - Horizontal Pills */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-sm font-semibold text-slate-500 uppercase tracking-wider">Kategori</h2>
|
||||
{selectedCategory && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!generating) {
|
||||
setSelectedCategory(null)
|
||||
setSubCategories([])
|
||||
setSelectedSubCategories([])
|
||||
setSubBreadcrumb([])
|
||||
}
|
||||
}}
|
||||
disabled={generating}
|
||||
className="text-xs text-slate-400 hover:text-slate-600 transition-colors"
|
||||
>
|
||||
Temizle
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{mainCategories.map((category) => (
|
||||
<button
|
||||
key={category.id}
|
||||
onClick={() => {
|
||||
if (!generating) {
|
||||
setSelectedCategory(category)
|
||||
fetchSubCategories(category.id)
|
||||
}
|
||||
}}
|
||||
disabled={generating}
|
||||
className={`px-4 py-2 rounded-full text-sm font-medium transition-all duration-200 ${
|
||||
selectedCategory?.id === category.id
|
||||
? 'bg-orange-500 text-white shadow-sm shadow-orange-200'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200 hover:text-slate-800'
|
||||
} ${generating ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
{category.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Category Selector - Miller Columns */}
|
||||
<CategorySelector onSelect={handleCategorySelect} disabled={generating} />
|
||||
|
||||
{/* Sub-Category Selection - Clean List */}
|
||||
{selectedCategory && (subCategories.length > 0 || loadingSubCategories) && (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-5 py-4 border-b border-slate-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
{subBreadcrumb.length > 0 && (
|
||||
<button
|
||||
onClick={() => !generating && handleBreadcrumbBack(subBreadcrumb.length >= 2 ? subBreadcrumb.length - 2 : -1)}
|
||||
disabled={generating}
|
||||
className="w-7 h-7 flex items-center justify-center rounded-md bg-slate-100 hover:bg-slate-200 text-slate-400 hover:text-slate-600 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 text-sm">
|
||||
<button
|
||||
onClick={() => !generating && subBreadcrumb.length > 0 && handleBreadcrumbBack(-1)}
|
||||
className={`font-medium transition-colors ${subBreadcrumb.length > 0 ? 'text-orange-500 hover:text-orange-600 cursor-pointer' : 'text-slate-800 cursor-default'}`}
|
||||
disabled={generating || subBreadcrumb.length === 0}
|
||||
>
|
||||
{selectedCategory.name}
|
||||
</button>
|
||||
{subBreadcrumb.map((crumb, index) => (
|
||||
<span key={crumb.id} className="flex items-center gap-1.5">
|
||||
<ChevronRight className="w-3 h-3 text-slate-300" />
|
||||
{index < subBreadcrumb.length - 1 ? (
|
||||
<button
|
||||
onClick={() => !generating && handleBreadcrumbBack(index)}
|
||||
className="text-orange-500 hover:text-orange-600 font-medium"
|
||||
disabled={generating}
|
||||
>
|
||||
{crumb.name}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-slate-800 font-medium">{crumb.name}</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowSearch(!showSearch)}
|
||||
className={`w-7 h-7 flex items-center justify-center rounded-md transition-colors ${
|
||||
showSearch ? 'bg-orange-100 text-orange-500' : 'bg-slate-100 text-slate-400 hover:bg-slate-200 hover:text-slate-600'
|
||||
}`}
|
||||
>
|
||||
<Search className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<span className="text-xs text-slate-400 tabular-nums">{subCategories.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search - collapsible */}
|
||||
{showSearch && (
|
||||
<div className="relative mt-3">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-slate-300" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Ara..."
|
||||
value={subCategorySearch}
|
||||
onChange={(e) => setSubCategorySearch(e.target.value)}
|
||||
disabled={generating}
|
||||
autoFocus
|
||||
className="w-full pl-9 pr-8 py-2 bg-slate-50 border border-slate-200 rounded-lg text-sm focus:outline-none focus:border-orange-300 focus:ring-1 focus:ring-orange-300 transition-all"
|
||||
/>
|
||||
{subCategorySearch && (
|
||||
<button
|
||||
onClick={() => setSubCategorySearch('')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-300 hover:text-slate-500"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selected chips */}
|
||||
{selectedSubCategories.length > 0 && (
|
||||
<div className="px-5 py-3 border-b border-slate-100 bg-orange-50/40">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-[11px] font-semibold text-orange-500 uppercase tracking-wider shrink-0">
|
||||
{selectedSubCategories.length} seçili
|
||||
</span>
|
||||
<div className="w-px h-4 bg-orange-200 shrink-0" />
|
||||
{selectedSubCategories.map((cat) => (
|
||||
<span
|
||||
key={cat.id}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 bg-white rounded-full text-xs font-medium text-slate-700 border border-orange-200 shadow-sm"
|
||||
>
|
||||
{cat.name}
|
||||
<button
|
||||
onClick={() => !generating && setSelectedSubCategories(prev => prev.filter(c => c.id !== cat.id))}
|
||||
disabled={generating}
|
||||
className="text-slate-300 hover:text-red-400 transition-colors ml-0.5"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<button
|
||||
onClick={() => !generating && setSelectedSubCategories([])}
|
||||
disabled={generating}
|
||||
className="text-[11px] text-slate-400 hover:text-red-400 font-medium transition-colors ml-auto shrink-0"
|
||||
>
|
||||
Temizle
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* List */}
|
||||
{loadingSubCategories ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-orange-400" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-[420px] overflow-y-auto">
|
||||
{/* Select all row */}
|
||||
<div
|
||||
onClick={() => {
|
||||
if (generating) return
|
||||
const allIds = new Set(selectedSubCategories.map(c => c.id))
|
||||
const allSelected = subCategories.every(c => allIds.has(c.id))
|
||||
if (allSelected) {
|
||||
setSelectedSubCategories([])
|
||||
} else {
|
||||
const newOnes = subCategories.filter(c => !allIds.has(c.id))
|
||||
setSelectedSubCategories(prev => [...prev, ...newOnes])
|
||||
}
|
||||
}}
|
||||
className={`flex items-center px-5 py-2.5 border-b border-slate-100 cursor-pointer hover:bg-slate-50 transition-colors ${generating ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<div className={`w-[18px] h-[18px] rounded border-2 flex items-center justify-center mr-3 shrink-0 transition-colors ${
|
||||
subCategories.length > 0 && subCategories.every(c => selectedSubCategories.some(s => s.id === c.id))
|
||||
? 'bg-orange-500 border-orange-500'
|
||||
: 'border-slate-300'
|
||||
}`}>
|
||||
{subCategories.length > 0 && subCategories.every(c => selectedSubCategories.some(s => s.id === c.id)) && (
|
||||
<Check className="w-3 h-3 text-white" strokeWidth={3} />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">
|
||||
Tümünü seç ({selectedSubCategories.length}/{subCategories.length})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{filteredSubCategories.length > 0 ? (
|
||||
filteredSubCategories.map((subCat) => {
|
||||
const isSelected = selectedSubCategories.some(c => c.id === subCat.id)
|
||||
const hasChildren = subCat.children_count > 0
|
||||
return (
|
||||
<div
|
||||
key={subCat.id}
|
||||
className={`group flex items-center px-5 py-3 border-b border-slate-50 transition-colors ${
|
||||
isSelected ? 'bg-orange-50/60' : 'hover:bg-slate-50/80'
|
||||
} ${generating ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
{/* Checkbox */}
|
||||
<button
|
||||
onClick={() => toggleSubCategory(subCat)}
|
||||
disabled={generating}
|
||||
className="mr-3 shrink-0"
|
||||
>
|
||||
<div className={`w-[18px] h-[18px] rounded border-2 flex items-center justify-center transition-all duration-150 ${
|
||||
isSelected
|
||||
? 'bg-orange-500 border-orange-500 scale-105'
|
||||
: 'border-slate-300 group-hover:border-slate-400'
|
||||
}`}>
|
||||
{isSelected && <Check className="w-3 h-3 text-white" strokeWidth={3} />}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Name - click to select */}
|
||||
<button
|
||||
onClick={() => toggleSubCategory(subCat)}
|
||||
disabled={generating}
|
||||
className="flex-1 text-left min-w-0"
|
||||
>
|
||||
<span className={`text-sm font-medium ${isSelected ? 'text-orange-900' : 'text-slate-700'}`}>
|
||||
{subCat.name}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Children count + drill down */}
|
||||
{hasChildren && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDrillDown(subCat)
|
||||
}}
|
||||
disabled={generating}
|
||||
className="group/drill flex items-center gap-1.5 ml-3 px-2.5 py-1.5 rounded-lg bg-slate-100 text-slate-500 hover:text-orange-600 hover:bg-orange-100 transition-all shrink-0 border border-transparent hover:border-orange-200"
|
||||
title="Alt kategorileri gör"
|
||||
>
|
||||
<span className="text-xs tabular-nums font-medium">{subCat.children_count}</span>
|
||||
<ChevronRight className="w-3.5 h-3.5 transition-transform group-hover/drill:translate-x-0.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
<p className="text-sm">Sonuç bulunamadı</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Generate Button - compact inline */}
|
||||
{/* Generate Button */}
|
||||
{selectedCategory && !generating && (
|
||||
<div className="flex items-center justify-between bg-white rounded-xl shadow-sm border border-slate-200 px-5 py-4">
|
||||
<p className="text-sm text-slate-500">
|
||||
<span className="font-semibold text-slate-800">{selectedCategory.name}</span>
|
||||
<span className="mx-1.5 text-slate-300">·</span>
|
||||
{selectedSubCategories.length > 0 ? (
|
||||
<span>{selectedSubCategories.length} seçili kategori</span>
|
||||
) : (
|
||||
<span>{selectedCategory.children_count || 0} alt kategori</span>
|
||||
)}
|
||||
<span className="text-xs text-slate-400">{selectedCategory.path}</span>
|
||||
</p>
|
||||
<button
|
||||
onClick={handleGenerateReport}
|
||||
|
||||
Reference in New Issue
Block a user