refactor: modernize MaterialTypeManagementDialog UI and fix admin hover overlap
Restructure the dialog with a card-grid layout, KeywordCard subcomponent, and smooth hover animations. Fix admin view where manager badge and delete button overlapped by using flex layout with translate and max-width transitions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,19 +1,30 @@
|
|||||||
/**
|
/**
|
||||||
* Material Type Management Dialog
|
* Material Type Management Dialog (Modern UI Refactor)
|
||||||
*
|
*
|
||||||
* Provides a dialog for managing material type keywords used to identify
|
* Provides a dialog for managing material type keywords used to identify
|
||||||
* materials for deletion. Admin users can see all records and filter by manager.
|
* materials for deletion. Admin users can see all records and filter by manager.
|
||||||
* Regular users can only see and edit their own records.
|
* Regular users can only see and edit their own records.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'
|
||||||
import { Plus, Trash2, Save, RotateCcw, Users } from 'lucide-react'
|
import {
|
||||||
import { Modal } from './ui/Modal'
|
X,
|
||||||
|
Plus,
|
||||||
|
RotateCcw,
|
||||||
|
Search,
|
||||||
|
Trash2,
|
||||||
|
User,
|
||||||
|
CloudUpload,
|
||||||
|
CheckCircle2,
|
||||||
|
AlertCircle,
|
||||||
|
Users
|
||||||
|
} from 'lucide-react'
|
||||||
import { showSuccess, showError, showInfo } from '../stores/useAppStore'
|
import { showSuccess, showError, showInfo } from '../stores/useAppStore'
|
||||||
import { ConfirmDialog } from './ui/ConfirmDialog'
|
import { ConfirmDialog } from './ui/ConfirmDialog'
|
||||||
import { useConfirmDialog } from './ui/useConfirmDialog'
|
import { useConfirmDialog } from './ui/useConfirmDialog'
|
||||||
import { useLogger } from '../hooks/useLogger'
|
import { useLogger } from '../hooks/useLogger'
|
||||||
|
|
||||||
|
// --- Interfaces ---
|
||||||
interface MaterialTypeRecord {
|
interface MaterialTypeRecord {
|
||||||
id?: number
|
id?: number
|
||||||
materialName: string
|
materialName: string
|
||||||
@@ -21,6 +32,7 @@ interface MaterialTypeRecord {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface RowState {
|
interface RowState {
|
||||||
|
localId: string // Stable ID for React mapping during edits/filters
|
||||||
record: MaterialTypeRecord
|
record: MaterialTypeRecord
|
||||||
state: 'original' | 'new' | 'modified' | 'deleted'
|
state: 'original' | 'new' | 'modified' | 'deleted'
|
||||||
originalRecord?: MaterialTypeRecord
|
originalRecord?: MaterialTypeRecord
|
||||||
@@ -34,39 +46,172 @@ interface MaterialTypeManagementDialogProps {
|
|||||||
triggerRef?: React.RefObject<HTMLButtonElement | null>
|
triggerRef?: React.RefObject<HTMLButtonElement | null>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- KeywordCard Component (Handles individual items) ---
|
||||||
|
interface KeywordCardProps {
|
||||||
|
item: RowState
|
||||||
|
isAdmin: boolean
|
||||||
|
managers: string[]
|
||||||
|
onUpdate: (localId: string, newRecord: Partial<MaterialTypeRecord>) => void
|
||||||
|
onDelete: (localId: string) => void
|
||||||
|
onRestore: (localId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const KeywordCard: React.FC<KeywordCardProps> = ({ item, isAdmin, managers, onUpdate, onDelete, onRestore }) => {
|
||||||
|
const [isEditing, setIsEditing] = useState(item.state === 'new');
|
||||||
|
const [text, setText] = useState(item.record.materialName);
|
||||||
|
const [manager, setManager] = useState(item.record.managerName);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEditing && inputRef.current) {
|
||||||
|
inputRef.current.focus();
|
||||||
|
if (item.state !== 'new') {
|
||||||
|
inputRef.current.select();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isEditing, item.state]);
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
const trimmedText = text.trim();
|
||||||
|
if (!trimmedText) {
|
||||||
|
onDelete(item.localId); // Delete if empty
|
||||||
|
} else {
|
||||||
|
setIsEditing(false);
|
||||||
|
if (trimmedText !== item.record.materialName || manager !== item.record.managerName) {
|
||||||
|
onUpdate(item.localId, { materialName: trimmedText, managerName: manager });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter') handleSave();
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setText(item.record.materialName);
|
||||||
|
setManager(item.record.managerName);
|
||||||
|
setIsEditing(false);
|
||||||
|
if (item.state === 'new') onDelete(item.localId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Deleted State View
|
||||||
|
if (item.state === 'deleted') {
|
||||||
|
return (
|
||||||
|
<div className="group relative flex items-center h-10 px-3 bg-red-50/50 border border-red-200 rounded-lg transition-all">
|
||||||
|
<span className="flex-1 truncate text-sm text-red-700/60 font-medium line-through mr-6">
|
||||||
|
{item.record.materialName}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onRestore(item.localId); }}
|
||||||
|
className="absolute right-2 p-1.5 text-red-400 hover:text-red-600 hover:bg-red-100 rounded-md transition-all"
|
||||||
|
title="撤销删除"
|
||||||
|
>
|
||||||
|
<RotateCcw size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Editing State View
|
||||||
|
if (isEditing) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative flex items-center h-10 px-3 bg-indigo-50 border border-indigo-400 rounded-lg shadow-sm ring-2 ring-indigo-100 transition-all"
|
||||||
|
onBlur={(e) => {
|
||||||
|
// Delay save to allow dropdown clicks. Checks if new focus is outside this container.
|
||||||
|
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||||
|
handleSave();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="w-1.5 h-1.5 rounded-full bg-indigo-500 mr-2 flex-shrink-0"></div>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="关键词..."
|
||||||
|
className="flex-1 w-full min-w-[80px] bg-transparent outline-none text-sm font-medium text-indigo-900 placeholder-indigo-300"
|
||||||
|
/>
|
||||||
|
{isAdmin && (
|
||||||
|
<select
|
||||||
|
value={manager}
|
||||||
|
onChange={(e) => setManager(e.target.value)}
|
||||||
|
className="ml-2 bg-white outline-none text-xs text-indigo-700 border border-indigo-200 rounded px-1 py-0.5 focus:ring-2 focus:ring-indigo-200"
|
||||||
|
>
|
||||||
|
<option value="">负责人</option>
|
||||||
|
{managers.map(m => <option key={m} value={m}>{m}</option>)}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal / Modified State View
|
||||||
|
const isModified = item.state === 'modified' || item.state === 'new';
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={() => setIsEditing(true)}
|
||||||
|
className={`group relative flex items-center h-10 px-3 bg-white border rounded-lg cursor-pointer transition-all duration-200 hover:shadow-md hover:border-indigo-300
|
||||||
|
${isModified ? 'border-amber-300 bg-amber-50/30' : 'border-slate-200'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className={`w-1.5 h-1.5 rounded-full mr-2 flex-shrink-0 transition-colors
|
||||||
|
${isModified ? 'bg-amber-400' : 'bg-slate-300 group-hover:bg-indigo-400'}
|
||||||
|
`}></div>
|
||||||
|
|
||||||
|
<span className="flex-1 truncate text-sm text-slate-700 font-medium group-hover:text-indigo-700 transition-colors mr-2">
|
||||||
|
{item.record.materialName}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="flex-shrink-0 flex items-center">
|
||||||
|
{isAdmin && (
|
||||||
|
<span className="text-[10px] bg-slate-100 text-slate-500 px-1.5 py-0.5 rounded truncate max-w-[60px] group-hover:bg-indigo-50 group-hover:text-indigo-500 group-hover:-translate-x-2 transition-all duration-300 ease-in-out">
|
||||||
|
{item.record.managerName || '未指定'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onDelete(item.localId); }}
|
||||||
|
className="p-1.5 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-md max-w-0 overflow-hidden opacity-0 group-hover:max-w-8 group-hover:opacity-100 transition-all duration-200"
|
||||||
|
title="删除"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// --- Main Dialog Component ---
|
||||||
export const MaterialTypeManagementDialog: React.FC<MaterialTypeManagementDialogProps> = ({
|
export const MaterialTypeManagementDialog: React.FC<MaterialTypeManagementDialogProps> = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
currentUsername,
|
currentUsername,
|
||||||
triggerRef
|
// triggerRef // Retained for compatibility, but layout uses custom overlay
|
||||||
}) => {
|
}) => {
|
||||||
const [rows, setRows] = useState<RowState[]>([])
|
const [rows, setRows] = useState<RowState[]>([])
|
||||||
const [managers, setManagers] = useState<string[]>([])
|
const [managers, setManagers] = useState<string[]>([])
|
||||||
const [selectedManagers, setSelectedManagers] = useState<Set<string>>(new Set())
|
const [selectedManagers, setSelectedManagers] = useState<Set<string>>(new Set())
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [editingCell, setEditingCell] = useState<{ rowIndex: number; field: string } | null>(null)
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [editValue, setEditValue] = useState('')
|
|
||||||
const [selectedRowIndex, setSelectedRowIndex] = useState<number | null>(null)
|
|
||||||
const logger = useLogger('MaterialType')
|
const logger = useLogger('MaterialType')
|
||||||
|
|
||||||
const tableRef = useRef<HTMLTableElement>(null)
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
|
||||||
const selectRef = useRef<HTMLSelectElement>(null)
|
|
||||||
|
|
||||||
// Confirmation dialog hook
|
|
||||||
const { confirm, dialog: confirmDialog } = useConfirmDialog()
|
const { confirm, dialog: confirmDialog } = useConfirmDialog()
|
||||||
|
|
||||||
// Calculate pending changes count
|
|
||||||
const pendingCount = rows.filter(
|
const pendingCount = rows.filter(
|
||||||
(r) => r.state === 'new' || r.state === 'modified' || r.state === 'deleted'
|
(r) => r.state === 'new' || r.state === 'modified' || r.state === 'deleted'
|
||||||
).length
|
).length
|
||||||
|
const isSynced = pendingCount === 0;
|
||||||
|
|
||||||
|
// UUID Generator for stable React keys
|
||||||
|
const generateId = () => Math.random().toString(36).substring(2, 9) + Date.now().toString(36);
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
// Load managers list
|
|
||||||
const managersResult = await window.electron.materialType.getManagers()
|
const managersResult = await window.electron.materialType.getManagers()
|
||||||
if (managersResult.success && managersResult.data) {
|
if (managersResult.success && managersResult.data) {
|
||||||
setManagers(managersResult.data)
|
setManagers(managersResult.data)
|
||||||
@@ -75,28 +220,23 @@ export const MaterialTypeManagementDialog: React.FC<MaterialTypeManagementDialog
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load records
|
|
||||||
let records: MaterialTypeRecord[] = []
|
let records: MaterialTypeRecord[] = []
|
||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
const result = await window.electron.materialType.getAll()
|
const result = await window.electron.materialType.getAll()
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) records = result.data
|
||||||
records = result.data
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
const result = await window.electron.materialType.getByManager(currentUsername)
|
const result = await window.electron.materialType.getByManager(currentUsername)
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) records = result.data
|
||||||
records = result.data
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setRows(
|
setRows(
|
||||||
records.map((record) => ({
|
records.map((record) => ({
|
||||||
|
localId: generateId(),
|
||||||
record,
|
record,
|
||||||
state: 'original' as const,
|
state: 'original' as const,
|
||||||
originalRecord: { ...record }
|
originalRecord: { ...record }
|
||||||
}))
|
}))
|
||||||
)
|
)
|
||||||
setSelectedRowIndex(null)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to load material types', {
|
logger.error('Failed to load material types', {
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
@@ -108,137 +248,74 @@ export const MaterialTypeManagementDialog: React.FC<MaterialTypeManagementDialog
|
|||||||
}
|
}
|
||||||
}, [currentUsername, isAdmin, logger])
|
}, [currentUsername, isAdmin, logger])
|
||||||
|
|
||||||
// Load data when dialog opens
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) void loadData()
|
||||||
void loadData()
|
|
||||||
}
|
|
||||||
}, [isOpen, loadData])
|
}, [isOpen, loadData])
|
||||||
|
|
||||||
// Focus input when editing starts
|
const filteredRows = useMemo(() => {
|
||||||
useEffect(() => {
|
let result = rows;
|
||||||
const activeElement = inputRef.current ?? selectRef.current
|
if (isAdmin && selectedManagers.size > 0) {
|
||||||
if (editingCell && activeElement) {
|
result = result.filter(row => selectedManagers.has(row.record.managerName) || row.state === 'new');
|
||||||
activeElement.focus()
|
|
||||||
if (activeElement instanceof HTMLInputElement) {
|
|
||||||
activeElement.select()
|
|
||||||
}
|
}
|
||||||
|
if (searchQuery) {
|
||||||
|
const lowerQuery = searchQuery.toLowerCase();
|
||||||
|
result = result.filter(row => row.record.materialName.toLowerCase().includes(lowerQuery));
|
||||||
}
|
}
|
||||||
}, [editingCell])
|
return result;
|
||||||
|
}, [rows, isAdmin, selectedManagers, searchQuery])
|
||||||
|
|
||||||
// Filter rows by selected managers (admin only)
|
|
||||||
const filteredRows = React.useMemo(() => {
|
|
||||||
if (!isAdmin) return rows
|
|
||||||
if (selectedManagers.size === 0) return rows
|
|
||||||
return rows.filter((row) => selectedManagers.has(row.record.managerName) || row.state === 'new')
|
|
||||||
}, [rows, isAdmin, selectedManagers])
|
|
||||||
|
|
||||||
// Insert new row
|
// --- Row Actions ---
|
||||||
const insertNewRow = useCallback(() => {
|
const handleAdd = () => {
|
||||||
const newRow: RowState = {
|
const newRow: RowState = {
|
||||||
record: {
|
localId: generateId(),
|
||||||
materialName: '',
|
record: { materialName: '', managerName: isAdmin ? '' : currentUsername },
|
||||||
managerName: isAdmin ? '' : currentUsername
|
|
||||||
},
|
|
||||||
state: 'new'
|
state: 'new'
|
||||||
}
|
}
|
||||||
|
setRows(prev => [newRow, ...prev])
|
||||||
// 在 setRows 回调中计算索引并设置编辑状态
|
setSearchQuery('')
|
||||||
setRows((prev) => {
|
|
||||||
const newIndex = prev.length
|
|
||||||
setTimeout(() => {
|
|
||||||
setEditingCell({ rowIndex: newIndex, field: 'materialName' })
|
|
||||||
setEditValue('')
|
|
||||||
}, 0)
|
|
||||||
return [...prev, newRow]
|
|
||||||
})
|
|
||||||
}, [isAdmin, currentUsername])
|
|
||||||
|
|
||||||
// Delete row
|
|
||||||
const deleteRow = useCallback((index: number) => {
|
|
||||||
setRows((prev) => {
|
|
||||||
const newRows = [...prev]
|
|
||||||
const row = newRows[index]
|
|
||||||
if (row.state === 'new') {
|
|
||||||
// Remove new rows directly
|
|
||||||
newRows.splice(index, 1)
|
|
||||||
} else {
|
|
||||||
// Mark existing rows as deleted
|
|
||||||
newRows[index] = { ...row, state: 'deleted' }
|
|
||||||
}
|
}
|
||||||
return newRows
|
|
||||||
})
|
|
||||||
setSelectedRowIndex(null)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Start editing a cell
|
const handleUpdate = (localId: string, updates: Partial<MaterialTypeRecord>) => {
|
||||||
const startEdit = useCallback(
|
setRows(prev => prev.map(row => {
|
||||||
(rowIndex: number, field: string) => {
|
if (row.localId !== localId) return row;
|
||||||
const row = rows[rowIndex]
|
return {
|
||||||
if (row.state === 'deleted') return
|
|
||||||
|
|
||||||
setEditingCell({ rowIndex, field })
|
|
||||||
setEditValue(row.record[field as keyof MaterialTypeRecord] as string)
|
|
||||||
},
|
|
||||||
[rows]
|
|
||||||
)
|
|
||||||
|
|
||||||
// Save edit
|
|
||||||
const saveEdit = useCallback(() => {
|
|
||||||
if (!editingCell) return
|
|
||||||
|
|
||||||
const { rowIndex, field } = editingCell
|
|
||||||
setRows((prev) => {
|
|
||||||
const newRows = [...prev]
|
|
||||||
const row = newRows[rowIndex]
|
|
||||||
const newValue = editValue.trim()
|
|
||||||
|
|
||||||
// Update the record
|
|
||||||
newRows[rowIndex] = {
|
|
||||||
...row,
|
...row,
|
||||||
record: {
|
record: { ...row.record, ...updates },
|
||||||
...row.record,
|
|
||||||
[field]: newValue
|
|
||||||
},
|
|
||||||
state: row.state === 'new' ? 'new' : 'modified'
|
state: row.state === 'new' ? 'new' : 'modified'
|
||||||
}
|
}
|
||||||
return newRows
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = (localId: string) => {
|
||||||
|
setRows(prev => {
|
||||||
|
const row = prev.find(r => r.localId === localId);
|
||||||
|
if (!row) return prev;
|
||||||
|
if (row.state === 'new') return prev.filter(r => r.localId !== localId);
|
||||||
|
return prev.map(r => r.localId === localId ? { ...r, state: 'deleted' } : r);
|
||||||
})
|
})
|
||||||
|
|
||||||
setEditingCell(null)
|
|
||||||
setEditValue('')
|
|
||||||
}, [editingCell, editValue])
|
|
||||||
|
|
||||||
// Cancel edit
|
|
||||||
const cancelEdit = useCallback(() => {
|
|
||||||
setEditingCell(null)
|
|
||||||
setEditValue('')
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Handle keyboard events
|
|
||||||
const handleKeyDown = useCallback(
|
|
||||||
(event: React.KeyboardEvent) => {
|
|
||||||
if (editingCell) {
|
|
||||||
if (event.key === 'Enter') {
|
|
||||||
saveEdit()
|
|
||||||
} else if (event.key === 'Escape') {
|
|
||||||
cancelEdit()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.key === 'Insert') {
|
const handleRestore = (localId: string) => {
|
||||||
event.preventDefault()
|
setRows(prev => prev.map(r => {
|
||||||
insertNewRow()
|
if (r.localId !== localId) return r;
|
||||||
} else if (event.key === 'Delete' && selectedRowIndex !== null) {
|
// Determine if it was modified before deletion
|
||||||
event.preventDefault()
|
const wasModified = r.originalRecord?.materialName !== r.record.materialName ||
|
||||||
deleteRow(selectedRowIndex)
|
r.originalRecord?.managerName !== r.record.managerName;
|
||||||
|
return { ...r, state: wasModified ? 'modified' : 'original' }
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReset = async () => {
|
||||||
|
if (pendingCount === 0) return
|
||||||
|
const confirmed = await confirm({
|
||||||
|
title: '确认重置',
|
||||||
|
message: '确定要放弃所有未保存的更改吗?',
|
||||||
|
variant: 'warning'
|
||||||
|
})
|
||||||
|
if (confirmed) void loadData()
|
||||||
}
|
}
|
||||||
},
|
|
||||||
[editingCell, selectedRowIndex, insertNewRow, deleteRow, saveEdit, cancelEdit]
|
|
||||||
)
|
|
||||||
|
|
||||||
// Save all changes
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
const toInsert: MaterialTypeRecord[] = []
|
const toInsert: MaterialTypeRecord[] = []
|
||||||
const toUpdate: { old: MaterialTypeRecord; new: MaterialTypeRecord }[] = []
|
const toUpdate: { old: MaterialTypeRecord; new: MaterialTypeRecord }[] = []
|
||||||
@@ -273,19 +350,11 @@ export const MaterialTypeManagementDialog: React.FC<MaterialTypeManagementDialog
|
|||||||
|
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
const result = await window.electron.materialType.upsertBatch({
|
const result = await window.electron.materialType.upsertBatch({ toInsert, toUpdate, toDelete })
|
||||||
toInsert,
|
const payload = result.success ? (result.data as { stats?: { success?: number; failed?: number } } | undefined) : undefined
|
||||||
toUpdate,
|
|
||||||
toDelete
|
|
||||||
})
|
|
||||||
const payload = result.success
|
|
||||||
? (result.data as { stats?: { success?: number; failed?: number } } | undefined)
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
showSuccess(
|
showSuccess(`保存完成!\n成功:${payload?.stats?.success || 0} 条\n失败:${payload?.stats?.failed || 0} 条`)
|
||||||
`保存完成!\n成功:${payload?.stats?.success || 0} 条\n失败:${payload?.stats?.failed || 0} 条`
|
|
||||||
)
|
|
||||||
await loadData()
|
await loadData()
|
||||||
} else {
|
} else {
|
||||||
showError(`保存失败:${result.error || '未知错误'}`)
|
showError(`保存失败:${result.error || '未知错误'}`)
|
||||||
@@ -294,29 +363,13 @@ export const MaterialTypeManagementDialog: React.FC<MaterialTypeManagementDialog
|
|||||||
showError(`保存失败:${error instanceof Error ? error.message : '未知错误'}`)
|
showError(`保存失败:${error instanceof Error ? error.message : '未知错误'}`)
|
||||||
logger.error('Failed to save material types', {
|
logger.error('Failed to save material types', {
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
inserts: toInsert.length,
|
inserts: toInsert.length, updates: toUpdate.length, deletes: toDelete.length
|
||||||
updates: toUpdate.length,
|
|
||||||
deletes: toDelete.length
|
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset changes
|
|
||||||
const handleReset = async () => {
|
|
||||||
if (pendingCount === 0) return
|
|
||||||
const confirmed = await confirm({
|
|
||||||
title: '确认重置',
|
|
||||||
message: '确定要放弃所有未保存的更改吗?',
|
|
||||||
variant: 'warning'
|
|
||||||
})
|
|
||||||
if (confirmed) {
|
|
||||||
void loadData()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle close with unsaved changes warning
|
|
||||||
const handleClose = async () => {
|
const handleClose = async () => {
|
||||||
if (pendingCount > 0) {
|
if (pendingCount > 0) {
|
||||||
const confirmed = await confirm({
|
const confirmed = await confirm({
|
||||||
@@ -329,252 +382,169 @@ export const MaterialTypeManagementDialog: React.FC<MaterialTypeManagementDialog
|
|||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get row background color
|
if (!isOpen) return null;
|
||||||
const getRowStyle = (row: RowState): React.CSSProperties => {
|
|
||||||
if (row.state === 'deleted') {
|
|
||||||
return { backgroundColor: '#fee2e2', textDecoration: 'line-through', opacity: 0.6 }
|
|
||||||
}
|
|
||||||
if (row.state === 'new') {
|
|
||||||
return { backgroundColor: '#dcfce7' }
|
|
||||||
}
|
|
||||||
if (row.state === 'modified') {
|
|
||||||
return { backgroundColor: '#fef9c3' }
|
|
||||||
}
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-slate-900/40 backdrop-blur-sm p-4 md:p-8 font-sans">
|
||||||
isOpen={isOpen}
|
<div className="w-full max-w-5xl bg-white rounded-2xl shadow-xl border border-slate-200/60 overflow-hidden flex flex-col h-[85vh] animate-in fade-in zoom-in-95 duration-200">
|
||||||
onClose={handleClose}
|
|
||||||
title="物料类型管理"
|
|
||||||
size="2xl"
|
|
||||||
triggerRef={triggerRef}
|
|
||||||
>
|
|
||||||
<div onKeyDown={handleKeyDown}>
|
|
||||||
{/* Manager filter (admin only) */}
|
|
||||||
{isAdmin && (
|
|
||||||
<div className="mb-4 p-3 bg-slate-50 rounded-lg border border-slate-200">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<div className="flex items-center gap-2 text-sm font-medium text-slate-700">
|
|
||||||
<Users size={16} />
|
|
||||||
按负责人筛选
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setSelectedManagers(new Set(managers))}
|
|
||||||
className="text-xs text-blue-600 hover:underline"
|
|
||||||
>
|
|
||||||
全选
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setSelectedManagers(new Set())}
|
|
||||||
className="text-xs text-slate-500 hover:underline"
|
|
||||||
>
|
|
||||||
取消全选
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{managers.map((manager) => (
|
|
||||||
<label
|
|
||||||
key={manager}
|
|
||||||
className="flex items-center gap-1.5 text-xs text-slate-600 cursor-pointer hover:bg-white px-2 py-1 rounded"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="rounded text-blue-600"
|
|
||||||
checked={selectedManagers.has(manager)}
|
|
||||||
onChange={(e) => {
|
|
||||||
setSelectedManagers((prev) => {
|
|
||||||
const newSet = new Set(prev)
|
|
||||||
if (e.target.checked) newSet.add(manager)
|
|
||||||
else newSet.delete(manager)
|
|
||||||
return newSet
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{manager}
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Toolbar */}
|
{/* --- Header --- */}
|
||||||
<div className="flex items-center justify-between mb-3">
|
<header className="px-6 py-5 border-b border-slate-100 flex justify-between items-start bg-slate-50/50">
|
||||||
<div className="flex items-center gap-2">
|
<div>
|
||||||
|
<div className="flex items-center space-x-2 text-xs font-semibold text-slate-400 tracking-wider mb-1 uppercase">
|
||||||
|
<span>Material Type Management</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-xl font-bold text-slate-800 tracking-tight">物料类型管理</h1>
|
||||||
|
<p className="text-sm text-slate-500 mt-1">
|
||||||
|
{isAdmin ? '集中维护和管理所有负责人的物料关键词。' : '管理属于您的物料关键词。'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={insertNewRow}
|
onClick={handleClose}
|
||||||
className="flex items-center gap-1.5 text-xs bg-green-50 border border-green-200 text-green-700 px-3 py-1.5 rounded hover:bg-green-100"
|
className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-full transition-colors"
|
||||||
>
|
>
|
||||||
<Plus size={14} /> 新增 (Insert)
|
<X size={20} />
|
||||||
</button>
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* --- Toolbar --- */}
|
||||||
|
<div className="px-6 py-4 border-b border-slate-100 bg-white flex flex-col sm:flex-row justify-between items-center gap-6 z-10">
|
||||||
|
<div className="flex items-center space-x-6 w-full sm:w-auto">
|
||||||
<button
|
<button
|
||||||
onClick={() => selectedRowIndex !== null && deleteRow(selectedRowIndex)}
|
onClick={handleAdd}
|
||||||
disabled={selectedRowIndex === null}
|
className="flex items-center justify-center px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium rounded-lg shadow-sm shadow-indigo-200 transition-all active:scale-95"
|
||||||
className="flex items-center gap-1.5 text-xs bg-red-50 border border-red-200 text-red-700 px-3 py-1.5 rounded hover:bg-red-100 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
>
|
||||||
<Trash2 size={14} /> 删除 (Delete)
|
<Plus size={16} className="mr-1.5" /> 新增
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleReset}
|
onClick={handleReset}
|
||||||
disabled={pendingCount === 0}
|
disabled={pendingCount === 0 || loading}
|
||||||
className="flex items-center gap-1.5 text-xs bg-slate-50 border border-slate-200 text-slate-700 px-3 py-1.5 rounded hover:bg-slate-100 disabled:opacity-50 disabled:cursor-not-allowed"
|
className="flex items-center justify-center px-3 py-2 bg-white border border-slate-200 hover:bg-slate-50 text-slate-600 text-sm font-medium rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<RotateCcw size={14} /> 重置
|
<RotateCcw size={16} className="mr-1.5 text-slate-400" /> 重置
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<div className="relative hidden sm:block">
|
||||||
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<Search size={14} className="text-slate-400" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<input
|
||||||
{pendingCount > 0 && (
|
type="text"
|
||||||
<span className="text-xs text-amber-600 bg-amber-50 px-2 py-1 rounded">
|
placeholder="搜索关键词..."
|
||||||
{pendingCount} 项待保存
|
value={searchQuery}
|
||||||
</span>
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
)}
|
className="pl-9 pr-4 py-2 bg-slate-50 border border-transparent outline-none focus:border-indigo-200 focus:bg-white rounded-lg text-sm text-slate-700 w-56 transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-6 w-full sm:w-auto justify-end">
|
||||||
|
<div className="flex items-center bg-slate-50 rounded-lg p-1 border border-slate-100">
|
||||||
|
<div className="flex items-center px-4 py-1.5 text-xs text-slate-600 border-r border-slate-200">
|
||||||
|
<span className="text-slate-400 mr-1.5">记录</span>
|
||||||
|
<span className="font-semibold text-slate-800">{filteredRows.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center px-4 py-1.5 text-xs text-slate-600 border-r border-slate-200">
|
||||||
|
<User size={12} className="mr-1.5 text-slate-400" />
|
||||||
|
<span className="truncate max-w-[80px]">{isAdmin ? '管理员视图' : currentUsername}</span>
|
||||||
|
</div>
|
||||||
|
<div className={`flex items-center px-4 py-1.5 text-xs font-medium ${isSynced ? 'text-emerald-600' : 'text-amber-600'}`}>
|
||||||
|
{isSynced ? <><CheckCircle2 size={14} className="mr-1" /> 已同步</> : <><AlertCircle size={14} className="mr-1" /> 待保存 {pendingCount}</>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={saving || pendingCount === 0}
|
disabled={pendingCount === 0 || saving}
|
||||||
className="flex items-center gap-1.5 text-xs bg-blue-500 text-white px-3 py-1.5 rounded hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
className={`flex items-center justify-center px-5 py-2 text-sm font-medium rounded-lg transition-all duration-300
|
||||||
|
${pendingCount > 0 ? 'bg-slate-800 hover:bg-slate-900 text-white shadow-md' : 'bg-slate-100 text-slate-400 cursor-not-allowed'}
|
||||||
|
`}
|
||||||
>
|
>
|
||||||
<Save size={14} /> {saving ? '保存中...' : '保存'}
|
{saving ? <RotateCcw size={16} className="mr-2 animate-spin" /> : <CloudUpload size={16} className="mr-2" />}
|
||||||
|
{saving ? '保存中...' : '保存更改'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table */}
|
{/* --- Admin Manager Filter --- */}
|
||||||
<div className="border border-slate-200 rounded-lg overflow-hidden max-h-[400px] overflow-y-auto">
|
{isAdmin && managers.length > 0 && (
|
||||||
{loading ? (
|
<div className="px-6 py-3 bg-slate-50/80 border-b border-slate-100 flex items-center overflow-x-auto hide-scrollbar">
|
||||||
<div className="flex items-center justify-center py-12 text-slate-500">加载中...</div>
|
<div className="flex items-center gap-3 text-xs font-medium text-slate-500 mr-8 flex-shrink-0">
|
||||||
) : (
|
<Users size={14} /> 负责人筛选
|
||||||
<table ref={tableRef} className="w-full text-sm">
|
</div>
|
||||||
<thead className="bg-slate-100 sticky top-0">
|
<div className="flex gap-3 flex-nowrap">
|
||||||
<tr>
|
<button
|
||||||
<th className="px-4 py-2 text-left font-medium text-slate-700 w-64">
|
onClick={() => setSelectedManagers(new Set(managers))}
|
||||||
物料名称关键词
|
className={`px-3 py-1 rounded-full text-xs font-medium whitespace-nowrap transition-colors ${
|
||||||
</th>
|
selectedManagers.size === managers.length ? 'bg-slate-800 text-white' : 'bg-white border border-slate-200 text-slate-600 hover:bg-slate-100'
|
||||||
<th className="px-4 py-2 text-left font-medium text-slate-700">负责人</th>
|
}`}
|
||||||
</tr>
|
>
|
||||||
</thead>
|
全选
|
||||||
<tbody className="divide-y divide-slate-100">
|
</button>
|
||||||
{filteredRows.filter((r) => r.state !== 'deleted').length === 0 ? (
|
{managers.map((manager) => {
|
||||||
<tr>
|
const isSelected = selectedManagers.has(manager);
|
||||||
<td colSpan={2} className="px-4 py-8 text-center text-slate-400">
|
|
||||||
暂无数据,点击"新增"按钮添加物料类型关键词
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
filteredRows
|
|
||||||
.filter((r) => r.state !== 'deleted')
|
|
||||||
.map((row, index) => {
|
|
||||||
const originalIndex = rows.indexOf(row)
|
|
||||||
const isSelected = selectedRowIndex === originalIndex
|
|
||||||
const isEditingMaterial =
|
|
||||||
editingCell?.rowIndex === originalIndex &&
|
|
||||||
editingCell?.field === 'materialName'
|
|
||||||
const isEditingManager =
|
|
||||||
editingCell?.rowIndex === originalIndex &&
|
|
||||||
editingCell?.field === 'managerName'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<button
|
||||||
key={index}
|
key={manager}
|
||||||
style={getRowStyle(row)}
|
onClick={() => {
|
||||||
className={`${isSelected ? 'ring-2 ring-blue-300 ring-inset' : ''} hover:bg-slate-50 cursor-pointer`}
|
const newSet = new Set(selectedManagers);
|
||||||
onClick={() => setSelectedRowIndex(originalIndex)}
|
isSelected ? newSet.delete(manager) : newSet.add(manager);
|
||||||
>
|
setSelectedManagers(newSet);
|
||||||
<td className="px-4 py-2 border-r border-slate-100">
|
|
||||||
{isEditingMaterial ? (
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="text"
|
|
||||||
value={editValue}
|
|
||||||
onChange={(e) => setEditValue(e.target.value)}
|
|
||||||
onBlur={saveEdit}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') saveEdit()
|
|
||||||
if (e.key === 'Escape') cancelEdit()
|
|
||||||
}}
|
}}
|
||||||
className="w-full px-2 py-1 border border-blue-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
className={`px-3 py-1 rounded-full text-xs whitespace-nowrap transition-colors ${
|
||||||
/>
|
isSelected ? 'bg-indigo-100 text-indigo-700 border border-indigo-200' : 'bg-white border border-slate-200 text-slate-600 hover:bg-slate-100'
|
||||||
) : (
|
}`}
|
||||||
<div
|
|
||||||
className="min-h-[24px] cursor-text"
|
|
||||||
onDoubleClick={() => startEdit(originalIndex, 'materialName')}
|
|
||||||
>
|
>
|
||||||
{row.record.materialName || (
|
{manager}
|
||||||
<span className="text-slate-400 italic">双击编辑</span>
|
</button>
|
||||||
)}
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2">
|
{/* --- Main Content Grid --- */}
|
||||||
{isEditingManager ? (
|
<div className="flex-1 overflow-y-auto bg-slate-50/50 p-6 relative">
|
||||||
isAdmin ? (
|
{loading ? (
|
||||||
<select
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
ref={selectRef}
|
<div className="flex flex-col items-center text-slate-400">
|
||||||
value={editValue}
|
<RotateCcw size={24} className="animate-spin mb-3 opacity-50" />
|
||||||
onChange={(e) => setEditValue(e.target.value)}
|
<span className="text-sm">加载数据中...</span>
|
||||||
onBlur={saveEdit}
|
</div>
|
||||||
onKeyDown={(e) => {
|
</div>
|
||||||
if (e.key === 'Enter') saveEdit()
|
) : filteredRows.length > 0 ? (
|
||||||
if (e.key === 'Escape') cancelEdit()
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-3 auto-rows-max">
|
||||||
}}
|
{filteredRows.map(row => (
|
||||||
className="w-full px-2 py-1 border border-blue-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
<KeywordCard
|
||||||
>
|
key={row.localId}
|
||||||
<option value="">选择负责人</option>
|
item={row}
|
||||||
{managers.map((m) => (
|
isAdmin={isAdmin}
|
||||||
<option key={m} value={m}>
|
managers={managers}
|
||||||
{m}
|
onUpdate={handleUpdate}
|
||||||
</option>
|
onDelete={handleDelete}
|
||||||
|
onRestore={handleRestore}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</select>
|
|
||||||
) : (
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="text"
|
|
||||||
value={editValue}
|
|
||||||
onChange={(e) => setEditValue(e.target.value)}
|
|
||||||
onBlur={saveEdit}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') saveEdit()
|
|
||||||
if (e.key === 'Escape') cancelEdit()
|
|
||||||
}}
|
|
||||||
className="w-full px-2 py-1 border border-blue-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className="min-h-[24px] cursor-text"
|
|
||||||
onDoubleClick={() => startEdit(originalIndex, 'managerName')}
|
|
||||||
>
|
|
||||||
{row.record.managerName || (
|
|
||||||
<span className="text-slate-400 italic">双击编辑</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : (
|
||||||
</td>
|
<div className="h-full flex flex-col items-center justify-center text-slate-400 space-y-3">
|
||||||
</tr>
|
<Search size={32} className="opacity-20" />
|
||||||
)
|
<p className="text-sm">未找到匹配的关键词记录</p>
|
||||||
})
|
</div>
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer info */}
|
{/* --- Footer --- */}
|
||||||
<div className="mt-3 text-xs text-slate-500 flex justify-between">
|
<footer className="px-6 py-3 bg-white border-t border-slate-100 flex justify-between items-center text-xs text-slate-400">
|
||||||
<span>
|
<p>支持直接点击卡片编辑名称,回车快速保存,清空内容即为删除。</p>
|
||||||
双击单元格编辑 | Insert 新增 | Delete 删除
|
<p>展示 <span className="font-semibold text-slate-600">{filteredRows.length}</span> / {rows.length} 条记录</p>
|
||||||
{isAdmin && ' | 绿色=新增 | 黄色=已修改'}
|
</footer>
|
||||||
</span>
|
|
||||||
<span>共 {filteredRows.filter((r) => r.state !== 'deleted').length} 条记录</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Confirmation Dialog */}
|
{/* Retain the original confirmation dialog */}
|
||||||
{confirmDialog && <ConfirmDialog {...confirmDialog} />}
|
{confirmDialog && <ConfirmDialog {...confirmDialog} />}
|
||||||
</Modal>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user