293 lines
9.8 KiB
TypeScript
293 lines
9.8 KiB
TypeScript
import React, { useEffect, useRef } from 'react';
|
||
import ReactDOM from 'react-dom';
|
||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||
import { useConfirm } from '../../../hooks/useConfirm';
|
||
import styles from './GroupRow.module.css';
|
||
import type { TableGroupNode } from '../FormGeneratorTable/FormGeneratorTable';
|
||
import { FaFolder, FaFolderOpen, FaList, FaPen, FaPlus } from 'react-icons/fa';
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Types
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export interface GroupBulkAction {
|
||
icon?: React.ReactNode;
|
||
title?: string;
|
||
variant?: 'default' | 'danger';
|
||
onClick: () => void;
|
||
disabled?: boolean;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// GroupFolderRow
|
||
// ---------------------------------------------------------------------------
|
||
|
||
interface GroupFolderRowProps {
|
||
node: TableGroupNode;
|
||
depth: number;
|
||
colSpan: number;
|
||
visibleCount: number;
|
||
isExpanded: boolean;
|
||
isEditing: boolean;
|
||
/** True while an ITEM is dragged over this row (drop item into group). */
|
||
isDragOver: boolean;
|
||
/** True while a GROUP is dragged over this row (nest group inside). */
|
||
isDragOverFromGroup: boolean;
|
||
bulkActions?: GroupBulkAction[];
|
||
onToggle: () => void;
|
||
onEditCommit: (name: string) => void;
|
||
onEditCancel: () => void;
|
||
onRename: () => void;
|
||
onAddSub: () => void;
|
||
// Item drag-drop
|
||
onItemDragOver: (e: React.DragEvent) => void;
|
||
onItemDrop: (e: React.DragEvent) => void;
|
||
onItemDragLeave: () => void;
|
||
// Group drag (this row is draggable)
|
||
onGroupDragStart: (e: React.DragEvent) => void;
|
||
onGroupDragEnd: () => void;
|
||
onGroupDrag?: (e: React.DragEvent) => void;
|
||
/** True while this group is being dragged leftward to pop out one level */
|
||
isDraggingOut?: boolean;
|
||
/** Hide this row via display:none (keeps it in DOM so drag operations don't break) */
|
||
hidden?: boolean;
|
||
// Group drop (another group dropped onto this)
|
||
onGroupDragOver: (e: React.DragEvent) => void;
|
||
onGroupDrop: (e: React.DragEvent) => void;
|
||
onGroupDragLeave: () => void;
|
||
}
|
||
|
||
export function GroupFolderRow({
|
||
node,
|
||
depth,
|
||
colSpan,
|
||
visibleCount,
|
||
isExpanded,
|
||
isEditing,
|
||
isDragOver,
|
||
isDragOverFromGroup,
|
||
isDraggingOut,
|
||
hidden,
|
||
bulkActions = [],
|
||
onToggle,
|
||
onEditCommit,
|
||
onEditCancel,
|
||
onRename,
|
||
onAddSub,
|
||
onItemDragOver,
|
||
onItemDrop,
|
||
onItemDragLeave,
|
||
onGroupDragStart,
|
||
onGroupDragEnd,
|
||
onGroupDrag,
|
||
onGroupDragOver,
|
||
onGroupDrop,
|
||
onGroupDragLeave,
|
||
}: GroupFolderRowProps) {
|
||
const { t } = useLanguage();
|
||
const { ConfirmDialog } = useConfirm();
|
||
const inputRef = useRef<HTMLInputElement>(null);
|
||
const totalCount = node.itemIds.length;
|
||
|
||
useEffect(() => {
|
||
if (isEditing && inputRef.current) {
|
||
inputRef.current.focus();
|
||
inputRef.current.select();
|
||
}
|
||
}, [isEditing]);
|
||
|
||
const indentPx = depth * 20;
|
||
|
||
const _rowClass = [
|
||
styles.groupFolderRow,
|
||
isDragOver ? styles.dragOver : '',
|
||
isDragOverFromGroup ? styles.dragOverGroup : '',
|
||
isDraggingOut ? styles.draggingOut : '',
|
||
].filter(Boolean).join(' ');
|
||
|
||
return (
|
||
<>
|
||
{typeof document !== 'undefined' && ReactDOM.createPortal(<ConfirmDialog />, document.body)}
|
||
|
||
<tr
|
||
className={_rowClass}
|
||
style={{ '--group-indent': `${indentPx}px`, display: hidden ? 'none' : undefined } as React.CSSProperties}
|
||
draggable={!isEditing}
|
||
onDragStart={onGroupDragStart}
|
||
onDrag={onGroupDrag}
|
||
onDragEnd={onGroupDragEnd}
|
||
// item drag-over
|
||
onDragOver={(e) => {
|
||
// distinguish item vs group drag via dataTransfer type
|
||
if (e.dataTransfer.types.includes('application/porta-group')) {
|
||
onGroupDragOver(e);
|
||
} else {
|
||
onItemDragOver(e);
|
||
}
|
||
}}
|
||
onDrop={(e) => {
|
||
if (e.dataTransfer.types.includes('application/porta-group')) {
|
||
onGroupDrop(e);
|
||
} else {
|
||
onItemDrop(e);
|
||
}
|
||
}}
|
||
onDragLeave={() => { onItemDragLeave(); onGroupDragLeave(); }}
|
||
onDragEnter={(e) => e.preventDefault()}
|
||
>
|
||
<td colSpan={colSpan} className={styles.folderCell}>
|
||
<div className={styles.folderInner}>
|
||
{/* Indent */}
|
||
{indentPx > 0 && <span className={styles.indent} style={{ width: indentPx }} />}
|
||
|
||
{/* Chevron */}
|
||
<button
|
||
className={`${styles.chevronBtn} ${isExpanded ? styles.chevronOpen : ''}`}
|
||
onClick={(e) => { e.stopPropagation(); onToggle(); }}
|
||
title={isExpanded ? t('Zuklappen') : t('Aufklappen')}
|
||
tabIndex={-1}
|
||
>
|
||
<span className={styles.chevronArrow} />
|
||
</button>
|
||
|
||
{/* Folder icon */}
|
||
<span className={styles.folderIcon}>
|
||
{isExpanded ? <FaFolderOpen /> : <FaFolder />}
|
||
</span>
|
||
|
||
{/* Name / inline input */}
|
||
{isEditing ? (
|
||
<input
|
||
ref={inputRef}
|
||
defaultValue={node.name}
|
||
className={styles.nameInput}
|
||
placeholder={t('Gruppenname…')}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') onEditCommit(e.currentTarget.value);
|
||
if (e.key === 'Escape') onEditCancel();
|
||
}}
|
||
onBlur={(e) => onEditCommit(e.target.value)}
|
||
/>
|
||
) : (
|
||
<span className={styles.groupName} onClick={(e) => { e.stopPropagation(); onToggle(); }}>
|
||
{node.name || <em className={styles.unnamed}>{t('(Unbenannt)')}</em>}
|
||
</span>
|
||
)}
|
||
|
||
{/* Item count badge */}
|
||
{!isEditing && (
|
||
<span className={styles.badge}>
|
||
{visibleCount < totalCount && totalCount > 0
|
||
? `${visibleCount} / ${totalCount}`
|
||
: String(totalCount)}
|
||
</span>
|
||
)}
|
||
|
||
{/* Drop hint */}
|
||
{(isDragOver || isDragOverFromGroup) && (
|
||
<span className={styles.dropHint}>
|
||
{isDragOverFromGroup ? t('Als Untergruppe ablegen') : t('Hierher ziehen')}
|
||
</span>
|
||
)}
|
||
|
||
{/* ── Bulk actions (delete all, custom batch) right after badge ── */}
|
||
{!isEditing && bulkActions.length > 0 && (
|
||
<>
|
||
<span className={styles.separator} />
|
||
<span className={styles.actions}>
|
||
{bulkActions.map((action, i) => (
|
||
<button
|
||
key={i}
|
||
className={`${styles.actionBtn} ${action.variant === 'danger' ? styles.actionBtnDanger : ''}`}
|
||
title={action.title}
|
||
disabled={!!action.disabled}
|
||
onClick={(e) => { e.stopPropagation(); if (!action.disabled) action.onClick(); }}
|
||
>
|
||
{action.icon}
|
||
</button>
|
||
))}
|
||
</span>
|
||
</>
|
||
)}
|
||
|
||
{/* ── Group management: rename / add-subgroup ── */}
|
||
{!isEditing && (
|
||
<span className={styles.mgmtActions}>
|
||
<button onClick={(e) => { e.stopPropagation(); onRename(); }} title={t('Umbenennen')} className={styles.mgmtBtn}><FaPen /></button>
|
||
<button onClick={(e) => { e.stopPropagation(); onAddSub(); }} title={t('Untergruppe erstellen')} className={styles.mgmtBtn}><FaPlus /></button>
|
||
</span>
|
||
)}
|
||
|
||
<span style={{ flex: 1 }} />
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
</>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// BreadcrumbRow
|
||
// ---------------------------------------------------------------------------
|
||
|
||
interface BreadcrumbRowProps {
|
||
groupName: string;
|
||
totalItems: number;
|
||
colSpan: number;
|
||
onBack: () => void;
|
||
}
|
||
|
||
export function BreadcrumbRow({ groupName, totalItems, colSpan, onBack }: BreadcrumbRowProps) {
|
||
const { t } = useLanguage();
|
||
return (
|
||
<tr className={styles.breadcrumbRow}>
|
||
<td colSpan={colSpan} className={styles.breadcrumbCell}>
|
||
<div className={styles.breadcrumbInner}>
|
||
<button className={styles.backButton} onClick={onBack}>
|
||
← {t('Alle anzeigen')}
|
||
</button>
|
||
<span className={styles.breadcrumbSep}>›</span>
|
||
<span className={styles.breadcrumbCurrent}>{groupName}</span>
|
||
{totalItems > 0 && (
|
||
<span style={{ color: 'var(--color-text-secondary, #94a3b8)', fontSize: '11px' }}>
|
||
({totalItems} {t('Einträge')})
|
||
</span>
|
||
)}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// UngroupedRow — also a drop zone for removing items/groups from groups
|
||
// ---------------------------------------------------------------------------
|
||
|
||
interface UngroupedRowProps {
|
||
count: number;
|
||
colSpan: number;
|
||
isDragOver?: boolean;
|
||
onDragOver?: (e: React.DragEvent) => void;
|
||
onDrop?: (e: React.DragEvent) => void;
|
||
onDragLeave?: () => void;
|
||
}
|
||
|
||
export function UngroupedRow({ count, colSpan, isDragOver, onDragOver, onDrop, onDragLeave }: UngroupedRowProps) {
|
||
const { t } = useLanguage();
|
||
return (
|
||
<tr
|
||
className={`${styles.ungroupedRow} ${isDragOver ? styles.ungroupedDragOver : ''}`}
|
||
onDragOver={onDragOver}
|
||
onDrop={onDrop}
|
||
onDragLeave={onDragLeave}
|
||
onDragEnter={(e) => e.preventDefault()}
|
||
>
|
||
<td colSpan={colSpan} className={styles.ungroupedCell}>
|
||
<span className={styles.folderIcon}><FaList /></span>
|
||
{t('Nicht zugeordnet')}
|
||
<span className={styles.badge}>{count}</span>
|
||
{isDragOver && <span className={styles.dropHint}>{t('Aus Gruppe entfernen')}</span>}
|
||
</td>
|
||
</tr>
|
||
);
|
||
}
|