Files
MindSift/frontend/src/features/triage/ManageColumnsModal.tsx
T
sandrodachandClaude Opus 5.5 83b3f27b3e
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m38s
feat(voice): microphone in the add-task and add-item rows; phone fixes in the column dialog
VoiceInputButton brings the Quick Dump microphone into other input rows (useVoiceInput, appends
to the text already there, hidden when voice input is off or unsupported): the add-task row above
the task list and the add-item field of lists (desktop row and phone sheet).

Column dialog on phones: the add-column buttons go below the field, and the Erledigt toggle of a
column shows only its tick, so the bin stays on screen. Docs and changelog; v0.32.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 11:17:49 +01:00

422 lines
16 KiB
TypeScript

import React, { useState, useEffect, useRef } from 'react';
import {
X,
Plus,
Trash2,
ArrowUp,
ArrowDown,
RotateCcw,
Check,
Columns,
} from 'lucide-react';
import { KanbanColumn } from '../../lib/types';
import { triggerHaptic } from '../../lib/api';
interface ManageColumnsModalProps {
isOpen: boolean;
onClose: () => void;
initialColumns: KanbanColumn[];
onSave: (columns: KanbanColumn[]) => Promise<void>;
listTitle?: string;
}
export const DEFAULT_KANBAN_COLUMNS: KanbanColumn[] = [
{ id: 'todo', name: 'Zu erledigen' },
{ id: 'in_progress', name: 'In Bearbeitung', color: 'accent' },
{ id: 'waiting', name: 'Wartet auf andere', color: 'amber' },
{ id: 'done', name: 'Erledigt', isDone: true, color: 'emerald' },
];
// Wrapper and content split so the hooks always run in the same number (see TaskDetailModal)
// Colours a column can get; all come from the theme (blue and purple via --color-info / --color-purple)
export const COLUMN_COLORS: { id: string; label: string; cls: string }[] = [
{ id: 'gray', label: 'Grau', cls: 'bg-faint' },
{ id: 'slate', label: 'Anthrazit', cls: 'bg-main' },
{ id: 'green', label: 'Grün', cls: 'bg-status-ok' },
{ id: 'yellow', label: 'Gelb', cls: 'bg-status-warn' },
{ id: 'blue', label: 'Blau', cls: 'bg-tone-blue' },
{ id: 'red', label: 'Rot', cls: 'bg-status-err' },
{ id: 'purple', label: 'Lila', cls: 'bg-tone-purple' },
{ id: 'accent', label: 'Akzentfarbe', cls: 'bg-accent' },
];
// Names the server's default columns use
const COLOR_ALIASES: Record<string, string> = { emerald: 'green', amber: 'yellow' };
const columnColorId = (col: KanbanColumn): string => {
if (col.color) return COLOR_ALIASES[col.color] || col.color;
// Columns without a colour (older boards) keep the colour of their role
if (col.isDone) return 'green';
if (col.id === 'in_progress') return 'accent';
if (col.id === 'waiting') return 'yellow';
return 'gray';
};
// Dot colour of a status column (Kanban header, list view, task detail)
export const columnDotClass = (col: KanbanColumn | undefined): string =>
(col && COLUMN_COLORS.find((c) => c.id === columnColorId(col))?.cls) || 'bg-faint';
export const ManageColumnsModal: React.FC<ManageColumnsModalProps> = (props) =>
props.isOpen ? <ManageColumnsModalContent {...props} /> : null;
const ManageColumnsModalContent: React.FC<ManageColumnsModalProps> = ({
isOpen,
onClose,
initialColumns,
onSave,
listTitle,
}) => {
const [columns, setColumns] = useState<KanbanColumn[]>(() => {
return initialColumns && initialColumns.length > 0 ? initialColumns : DEFAULT_KANBAN_COLUMNS;
});
const [newColName, setNewColName] = useState('');
const [newColIsDone, setNewColIsDone] = useState(false);
const [saving, setSaving] = useState(false);
// Colour menu: opens at the column's dot like a context menu (fixed, so the scrolling list cannot clip it)
const [colorMenu, setColorMenu] = useState<{ id: string; left: number; top?: number; bottom?: number } | null>(null);
const colorMenuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!colorMenu) return;
const close = (e: Event) => {
const target = e.target as HTMLElement;
if (colorMenuRef.current?.contains(target) || target.closest?.('[data-color-trigger]')) return;
setColorMenu(null);
};
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setColorMenu(null);
const dismiss = () => setColorMenu(null);
document.addEventListener('pointerdown', close);
document.addEventListener('keydown', onKey);
window.addEventListener('resize', dismiss);
return () => {
document.removeEventListener('pointerdown', close);
document.removeEventListener('keydown', onKey);
window.removeEventListener('resize', dismiss);
};
}, [colorMenu]);
const openColorMenu = (id: string, trigger: HTMLElement) => {
if (colorMenu?.id === id) {
setColorMenu(null);
return;
}
const rect = trigger.getBoundingClientRect();
const menuHeight = COLUMN_COLORS.length * 36 + 12;
const left = Math.min(rect.left, window.innerWidth - 200);
// Below the dot if there is room, else above it
setColorMenu(
rect.bottom + menuHeight + 8 < window.innerHeight
? { id, left, top: rect.bottom + 4 }
: { id, left, bottom: window.innerHeight - rect.top + 4 }
);
};
useEffect(() => {
setColumns(initialColumns && initialColumns.length > 0 ? initialColumns : DEFAULT_KANBAN_COLUMNS);
}, [initialColumns, isOpen]);
const handleMove = (index: number, direction: 'up' | 'down') => {
triggerHaptic(10);
const targetIndex = direction === 'up' ? index - 1 : index + 1;
if (targetIndex < 0 || targetIndex >= columns.length) return;
const next = [...columns];
const temp = next[index];
next[index] = next[targetIndex];
next[targetIndex] = temp;
setColumns(next);
};
const handleRename = (id: string, newName: string) => {
setColumns((prev) =>
prev.map((c) => (c.id === id ? { ...c, name: newName } : c))
);
};
const handleColor = (id: string, color: string) => {
triggerHaptic(10);
setColumns((prev) => prev.map((c) => (c.id === id ? { ...c, color } : c)));
setColorMenu(null);
};
const handleToggleDone = (id: string) => {
triggerHaptic(10);
setColumns((prev) =>
prev.map((c) => (c.id === id ? { ...c, isDone: !c.isDone } : c))
);
};
const handleDelete = (id: string) => {
if (columns.length <= 1) {
alert('Mindestens eine Spalte muss vorhanden sein.');
return;
}
triggerHaptic(10);
setColumns((prev) => prev.filter((c) => c.id !== id));
};
const handleAddColumn = (e: React.FormEvent) => {
e.preventDefault();
const name = newColName.trim();
if (!name) return;
triggerHaptic(10);
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '_').slice(0, 20) || 'col';
const id = `${slug}_${Date.now().toString(36).slice(-4)}`;
setColumns((prev) => [
...prev,
{
id,
name,
isDone: newColIsDone,
},
]);
setNewColName('');
setNewColIsDone(false);
};
const handleResetToDefault = () => {
if (window.confirm('Spalten auf Standard zurücksetzen? (Zu erledigen, In Bearbeitung, Wartet auf andere, Erledigt)')) {
triggerHaptic(15);
setColumns(DEFAULT_KANBAN_COLUMNS);
}
};
const handleSave = async () => {
if (columns.length === 0) {
alert('Es muss mindestens eine Spalte vorhanden sein.');
return;
}
setSaving(true);
triggerHaptic(15);
try {
await onSave(columns);
onClose();
} catch (err: any) {
alert(err.message || 'Fehler beim Speichern der Spalten');
} finally {
setSaving(false);
}
};
return (
<div className="fixed inset-0 !m-0 modal-overlay z-50 flex items-center justify-center p-3 sm:p-4">
<div
className="w-full max-w-md panel-surface shadow-panel border border-border my-auto flex flex-col max-h-[90vh] overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-panel">
<div className="flex items-center gap-2">
<Columns className="w-4 h-4 text-accent" />
<div>
<h3 className="text-sm font-semibold text-main">Kanban-Spalten anpassen</h3>
{listTitle && (
<p className="text-[10px] text-muted truncate">Liste: {listTitle}</p>
)}
</div>
</div>
<button
type="button"
onClick={onClose}
className="text-muted hover:text-main p-1.5 transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Content */}
<div className="p-4 space-y-4 overflow-y-auto flex-1">
<p className="text-xs text-muted">
Passe die Spalten für das Kanban-Board an. Aufgaben können in jede beliebige Spalte verschoben werden.
</p>
{/* Columns List */}
<div className="space-y-2">
<label className="block text-xs font-semibold text-main">Aktive Spalten ({columns.length}):</label>
<div className="space-y-1.5 max-h-[45vh] overflow-y-auto pr-1" onScroll={() => setColorMenu(null)}>
{columns.map((col, idx) => (
<div key={col.id} className="p-2 bg-canvas border border-border rounded-card space-y-2">
<div className="flex items-center gap-2">
<div className="flex flex-col gap-0.5">
<button
type="button"
disabled={idx === 0}
onClick={() => handleMove(idx, 'up')}
className="text-muted hover:text-main disabled:opacity-20 p-0.5"
title="Nach oben"
>
<ArrowUp className="w-3 h-3" />
</button>
<button
type="button"
disabled={idx === columns.length - 1}
onClick={() => handleMove(idx, 'down')}
className="text-muted hover:text-main disabled:opacity-20 p-0.5"
title="Nach unten"
>
<ArrowDown className="w-3 h-3" />
</button>
</div>
<button
type="button"
data-color-trigger
onClick={(e) => openColorMenu(col.id, e.currentTarget)}
className="w-7 h-7 flex items-center justify-center rounded-btn border border-border bg-panel hover:border-strong flex-shrink-0"
title="Farbe der Spalte"
aria-label={`Farbe der Spalte ${col.name}`}
aria-haspopup="menu"
aria-expanded={colorMenu?.id === col.id}
>
<span className={`w-3 h-3 rounded-full ${columnDotClass(col)}`} />
</button>
<input
type="text"
value={col.name}
onChange={(e) => handleRename(col.id, e.target.value)}
className="flex-1 min-w-0 h-7 px-2 text-xs input-field"
placeholder="Spaltenname"
/>
{/* IsDone Toggle */}
<button
type="button"
onClick={() => handleToggleDone(col.id)}
className={`h-7 px-2 text-[10px] rounded-btn border transition-colors flex items-center gap-1 flex-shrink-0 ${
col.isDone
? 'bg-status-ok/15 border-status-ok text-status-ok font-semibold'
: 'bg-panel border-border text-faint hover:text-muted'
}`}
title={col.isDone ? 'Gilt als Erledigt' : 'Gilt als Offen'}
aria-label={col.isDone ? 'Gilt als Erledigt' : 'Gilt als Offen'}
aria-pressed={!!col.isDone}
>
<Check className={`w-2.5 h-2.5 ${col.isDone ? 'stroke-[3]' : 'opacity-40'}`} />
{/* Phones: only the tick, so the row keeps room for the bin */}
<span className="hidden sm:inline">Erledigt</span>
</button>
<button
type="button"
onClick={() => handleDelete(col.id)}
disabled={columns.length <= 1}
className="text-muted hover:text-status-err disabled:opacity-20 p-1 transition-colors"
title="Spalte löschen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
))}
</div>
</div>
{/* Add New Column Form */}
<form onSubmit={handleAddColumn} className="space-y-2 pt-2 border-t border-border">
<label className="block text-xs font-semibold text-main">Neue Spalte hinzufügen:</label>
{/* Phones: the buttons go below the field, so nothing is cut off */}
<div className="flex flex-col sm:flex-row sm:items-center gap-1.5">
<input
type="text"
value={newColName}
onChange={(e) => setNewColName(e.target.value)}
placeholder="z.B. Wartet auf Feedback, Review…"
className="w-full sm:w-auto sm:flex-1 min-w-0 h-11 sm:h-8 px-2.5 text-base sm:text-xs input-field"
/>
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={() => setNewColIsDone((prev) => !prev)}
className={`flex-1 sm:flex-none justify-center h-11 sm:h-8 px-2.5 text-xs rounded-btn border transition-colors flex items-center gap-1 ${
newColIsDone
? 'bg-status-ok/15 border-status-ok text-status-ok font-semibold'
: 'bg-panel border-border text-muted hover:text-main'
}`}
title="Sollen Aufgaben in dieser Spalte als erledigt gelten?"
>
<Check className="w-3 h-3" />
<span>Erledigt</span>
</button>
<button
type="submit"
disabled={!newColName.trim()}
className="flex-1 sm:flex-none justify-center btn-primary h-11 sm:h-8 px-3 text-xs flex items-center gap-1 disabled:opacity-40"
>
<Plus className="w-3.5 h-3.5" />
<span>Hinzufügen</span>
</button>
</div>
</div>
</form>
{/* Reset button */}
<div className="pt-2 flex justify-start">
<button
type="button"
onClick={handleResetToDefault}
className="text-xs text-muted hover:text-main flex items-center gap-1"
>
<RotateCcw className="w-3 h-3 text-accent" />
<span>Auf Standard-Spalten zurücksetzen</span>
</button>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2 px-4 py-2.5 border-t border-border bg-panel">
<button
type="button"
onClick={onClose}
className="btn-action h-7 px-3 text-xs"
>
Abbrechen
</button>
<button
type="button"
onClick={handleSave}
disabled={saving}
className="btn-primary h-7 px-3 text-xs disabled:opacity-50"
>
Spalten speichern
</button>
</div>
</div>
{colorMenu &&
(() => {
const col = columns.find((c) => c.id === colorMenu.id);
if (!col) return null;
return (
<div
ref={colorMenuRef}
role="menu"
aria-label={`Farbe der Spalte ${col.name}`}
style={{ left: colorMenu.left, top: colorMenu.top, bottom: colorMenu.bottom }}
className="fixed z-[60] w-44 p-1 bg-panel border border-border rounded-card shadow-panel"
onClick={(e) => e.stopPropagation()}
>
{COLUMN_COLORS.map((c) => {
const active = columnColorId(col) === c.id;
return (
<button
key={c.id}
type="button"
role="menuitemradio"
aria-checked={active}
onClick={() => handleColor(col.id, c.id)}
className={`w-full h-11 sm:h-8 px-2 rounded-btn text-xs flex items-center gap-2 text-left transition-colors ${
active ? 'bg-accent/10 text-main' : 'text-muted hover:text-main hover:bg-panel-hover'
}`}
>
<span className={`w-3 h-3 rounded-full flex-shrink-0 ${c.cls}`} />
<span className="flex-1">{c.label}</span>
{active && <Check className="w-3 h-3 text-accent stroke-[3]" />}
</button>
);
})}
</div>
);
})()}
</div>
);
};