feat: custom views accordion - save/apply/delete named views with filter+sort+group config
This commit is contained in:
@@ -19,7 +19,7 @@ import { SavedFilters } from '@/components/SavedFilters';
|
|||||||
import { SavedFilterBar } from '@/components/common/SavedFilterBar';
|
import { SavedFilterBar } from '@/components/common/SavedFilterBar';
|
||||||
import { TagSelector } from '@/components/tags/TagSelector';
|
import { TagSelector } from '@/components/tags/TagSelector';
|
||||||
import type { Tag } from '@/api/tags';
|
import type { Tag } from '@/api/tags';
|
||||||
import { ArrowDownAZ, ArrowUpZA, Bookmark, ChevronLeft, ExternalLink, LayoutGrid, List, Plus, Printer, Table2 } from 'lucide-react';
|
import { ArrowDownAZ, ArrowUpZA, Bookmark, ChevronLeft, ExternalLink, LayoutGrid, List, Plus, Printer, Table2, X } from 'lucide-react';
|
||||||
import { FilterPanel, applyFilters, emptyFilterState, type FilterState } from '@/components/contacts/FilterPanel';
|
import { FilterPanel, applyFilters, emptyFilterState, type FilterState } from '@/components/contacts/FilterPanel';
|
||||||
import { SortPanel, applySorting, emptySortState, type SortState } from '@/components/contacts/SortPanel';
|
import { SortPanel, applySorting, emptySortState, type SortState } from '@/components/contacts/SortPanel';
|
||||||
import { GroupPanel, applyGrouping, emptyGroupState, type GroupState, type GroupedContacts } from '@/components/contacts/GroupPanel';
|
import { GroupPanel, applyGrouping, emptyGroupState, type GroupState, type GroupedContacts } from '@/components/contacts/GroupPanel';
|
||||||
@@ -50,6 +50,8 @@ export function ContactsListPage() {
|
|||||||
const [groupState, setGroupState] = useState<GroupState>(emptyGroupState);
|
const [groupState, setGroupState] = useState<GroupState>(emptyGroupState);
|
||||||
const [viewsOpen, setViewsOpen] = useState(true);
|
const [viewsOpen, setViewsOpen] = useState(true);
|
||||||
const [foldersOpen, setFoldersOpen] = useState(true);
|
const [foldersOpen, setFoldersOpen] = useState(true);
|
||||||
|
const [savedViews, setSavedViews] = useState<{ id: string; name: string; filterState: FilterState; sortState: SortState; groupState: GroupState; selectedFilter: ContactFilter; viewMode: ContactViewMode }[]>([]);
|
||||||
|
const [activeViewId, setActiveViewId] = useState<string | null>(null);
|
||||||
const openWindow = useWindowStore((s) => s.openWindow);
|
const openWindow = useWindowStore((s) => s.openWindow);
|
||||||
|
|
||||||
// Debounce search
|
// Debounce search
|
||||||
@@ -195,6 +197,40 @@ export function ContactsListPage() {
|
|||||||
setActiveView('list');
|
setActiveView('list');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Save current view configuration as a custom view
|
||||||
|
const handleSaveView = useCallback(() => {
|
||||||
|
const name = window.prompt('Name für diese Ansicht:', 'Meine Ansicht');
|
||||||
|
if (!name) return;
|
||||||
|
const id = `view-${Date.now()}`;
|
||||||
|
setSavedViews((prev) => [...prev, {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
filterState: { ...filterState },
|
||||||
|
sortState: { ...sortState },
|
||||||
|
groupState: { ...groupState },
|
||||||
|
selectedFilter,
|
||||||
|
viewMode,
|
||||||
|
}]);
|
||||||
|
setActiveViewId(id);
|
||||||
|
}, [filterState, sortState, groupState, selectedFilter, viewMode]);
|
||||||
|
|
||||||
|
// Apply a saved custom view
|
||||||
|
const applySavedView = useCallback((view: { id: string; filterState: FilterState; sortState: SortState; groupState: GroupState; selectedFilter: ContactFilter; viewMode: ContactViewMode }) => {
|
||||||
|
setFilterState({ ...view.filterState });
|
||||||
|
setSortState({ ...view.sortState });
|
||||||
|
setGroupState({ ...view.groupState });
|
||||||
|
setSelectedFilter(view.selectedFilter);
|
||||||
|
setViewMode(view.viewMode);
|
||||||
|
setActiveViewId(view.id);
|
||||||
|
setPage(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Delete a saved custom view
|
||||||
|
const deleteSavedView = useCallback((id: string) => {
|
||||||
|
setSavedViews((prev) => prev.filter((v) => v.id !== id));
|
||||||
|
if (activeViewId === id) setActiveViewId(null);
|
||||||
|
}, [activeViewId]);
|
||||||
|
|
||||||
// Sort options
|
// Sort options
|
||||||
const sortOptions = useMemo(() => [
|
const sortOptions = useMemo(() => [
|
||||||
{ value: 'displayname', label: t('contacts.fullName') },
|
{ value: 'displayname', label: t('contacts.fullName') },
|
||||||
@@ -334,7 +370,7 @@ export function ContactsListPage() {
|
|||||||
];
|
];
|
||||||
registerItems('contacts', items);
|
registerItems('contacts', items);
|
||||||
return () => unregisterPlugin('contacts');
|
return () => unregisterPlugin('contacts');
|
||||||
}, [handleSearch, handleCreate, handleSelectFilter, t, registerItems, unregisterPlugin, selectedFilter, viewMode, sortOptions, viewModeOptions, filterState, contactType, sortState, groupState]);
|
}, [handleSearch, handleCreate, handleSelectFilter, handleSaveView, applySavedView, deleteSavedView, t, registerItems, unregisterPlugin, selectedFilter, viewMode, sortOptions, viewModeOptions, filterState, contactType, sortState, groupState, savedViews, activeViewId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full" data-testid="contacts-list-page">
|
<div className="flex flex-col h-full" data-testid="contacts-list-page">
|
||||||
@@ -349,7 +385,7 @@ export function ContactsListPage() {
|
|||||||
data-testid="contact-folder-pane"
|
data-testid="contact-folder-pane"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col h-full overflow-y-auto">
|
<div className="flex flex-col h-full overflow-y-auto">
|
||||||
{/* Accordion 1: Views */}
|
{/* Accordion 1: Custom Views */}
|
||||||
<div className="border-b border-secondary-200">
|
<div className="border-b border-secondary-200">
|
||||||
<button
|
<button
|
||||||
onClick={() => setViewsOpen(!viewsOpen)}
|
onClick={() => setViewsOpen(!viewsOpen)}
|
||||||
@@ -365,23 +401,67 @@ export function ContactsListPage() {
|
|||||||
</button>
|
</button>
|
||||||
{viewsOpen && (
|
{viewsOpen && (
|
||||||
<div className="px-3 pb-2 space-y-0.5">
|
<div className="px-3 pb-2 space-y-0.5">
|
||||||
{[
|
{/* Default view */}
|
||||||
{ value: 'list', label: t('contacts.viewList'), icon: <List className="w-3.5 h-3.5" strokeWidth={2} /> },
|
<button
|
||||||
{ value: 'table', label: t('contacts.viewTable'), icon: <Table2 className="w-3.5 h-3.5" strokeWidth={2} /> },
|
onClick={() => {
|
||||||
{ value: 'cards', label: t('contacts.viewCards'), icon: <LayoutGrid className="w-3.5 h-3.5" strokeWidth={2} /> },
|
setFilterState(emptyFilterState);
|
||||||
].map((opt) => (
|
setSortState(emptySortState);
|
||||||
<button
|
setGroupState(emptyGroupState);
|
||||||
key={opt.value}
|
setSelectedFilter('all');
|
||||||
onClick={() => setViewMode(opt.value as ContactViewMode)}
|
setViewMode('list');
|
||||||
className={`
|
}}
|
||||||
w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs text-left transition-colors
|
className={`
|
||||||
${viewMode === opt.value ? 'bg-primary-100 text-primary-700 font-medium' : 'text-secondary-600 hover:bg-secondary-100'}
|
w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs text-left transition-colors
|
||||||
`}
|
${!filterState.conditions.length && !sortState.conditions.length && !groupState.conditions.length && selectedFilter === 'all'
|
||||||
>
|
? 'bg-primary-100 text-primary-700 font-medium'
|
||||||
{opt.icon}
|
: 'text-secondary-600 hover:bg-secondary-100'}
|
||||||
{opt.label}
|
`}
|
||||||
</button>
|
>
|
||||||
))}
|
<List className="w-3.5 h-3.5" strokeWidth={2} />
|
||||||
|
Standardansicht
|
||||||
|
</button>
|
||||||
|
{/* Placeholder for saved custom views */}
|
||||||
|
{savedViews.length === 0 ? (
|
||||||
|
<div className="px-2 py-3 text-center">
|
||||||
|
<p className="text-[11px] text-secondary-400 mb-2">Keine benutzerdefinierten Ansichten</p>
|
||||||
|
<button
|
||||||
|
onClick={handleSaveView}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-1 text-[11px] text-primary-600 hover:bg-primary-50 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-3 h-3" />
|
||||||
|
Aktuelle Ansicht speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{savedViews.map((view) => (
|
||||||
|
<button
|
||||||
|
key={view.id}
|
||||||
|
onClick={() => applySavedView(view)}
|
||||||
|
className={`
|
||||||
|
w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs text-left transition-colors group
|
||||||
|
${activeViewId === view.id ? 'bg-primary-100 text-primary-700 font-medium' : 'text-secondary-600 hover:bg-secondary-100'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<Bookmark className="w-3.5 h-3.5 flex-shrink-0" strokeWidth={2} />
|
||||||
|
<span className="flex-1 truncate">{view.name}</span>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); deleteSavedView(view.id); }}
|
||||||
|
className="opacity-0 group-hover:opacity-100 text-secondary-400 hover:text-red-600 transition-opacity"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={handleSaveView}
|
||||||
|
className="w-full flex items-center gap-1 px-2 py-1.5 rounded text-xs text-primary-600 hover:bg-primary-50 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-3 h-3" />
|
||||||
|
Aktuelle Ansicht speichern
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user