Setup project structure, dependencies, and basic configuration for the ComFi application. Includes initial setup for Vite, React, TypeScript, Tailwind CSS, and essential development tools. Defines core types and provides a basic README for local development.
499 lines
22 KiB
TypeScript
499 lines
22 KiB
TypeScript
|
|
import React, { useState } from 'react';
|
|
import { Plus, MoreHorizontal, Clock, UserCircle, X, DollarSign, GripVertical, Trash2, Building2 } from 'lucide-react';
|
|
import { KanbanColumn, KanbanTask, Company, Receivable } from '../types';
|
|
import { useToast } from '../contexts/ToastContext';
|
|
import { CustomSelect } from './CustomSelect';
|
|
|
|
const initialColumns: KanbanColumn[] = [
|
|
{
|
|
id: 'todo',
|
|
title: 'A Fazer',
|
|
tasks: [
|
|
{ id: 't1', title: 'Criar contrato Uda Studios', priority: 'high', dueDate: '2024-05-15', value: 12000, description: 'Negociação referente ao projeto de redesign completo.' },
|
|
{ id: 't2', title: 'Revisar balanço trimestral', priority: 'medium', dueDate: '2024-05-20', value: 0, description: 'Verificar lançamentos de março e abril.' },
|
|
]
|
|
},
|
|
{
|
|
id: 'progress',
|
|
title: 'Em Progresso',
|
|
tasks: [
|
|
{ id: 't3', title: 'Design do Dashboard', priority: 'high', dueDate: '2024-05-18', value: 5000 },
|
|
]
|
|
},
|
|
{
|
|
id: 'review',
|
|
title: 'Revisão',
|
|
tasks: [
|
|
{ id: 't4', title: 'Aprovação de Orçamento', priority: 'low', dueDate: '2024-05-12', value: 3500 },
|
|
]
|
|
},
|
|
{
|
|
id: 'done',
|
|
title: 'Concluído',
|
|
tasks: [
|
|
{ id: 't5', title: 'Onboarding Angels Healthcare', priority: 'medium', dueDate: '2024-05-10', value: 15000 },
|
|
]
|
|
}
|
|
];
|
|
|
|
interface KanbanViewProps {
|
|
companies: Company[];
|
|
onAddReceivable: (receivable: Receivable) => void;
|
|
}
|
|
|
|
export const KanbanView: React.FC<KanbanViewProps> = ({ companies, onAddReceivable }) => {
|
|
const { addToast } = useToast();
|
|
const [columns, setColumns] = useState<KanbanColumn[]>(initialColumns);
|
|
|
|
// States para Modais
|
|
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
|
|
const [isColumnModalOpen, setIsColumnModalOpen] = useState(false);
|
|
|
|
// State de Edição/Criação
|
|
const [currentTask, setCurrentTask] = useState<Partial<KanbanTask>>({});
|
|
const [currentColumnId, setCurrentColumnId] = useState<string>('todo');
|
|
const [newColumnTitle, setNewColumnTitle] = useState('');
|
|
const [draggedTaskId, setDraggedTaskId] = useState<string | null>(null);
|
|
const [draggedSourceColumnId, setDraggedSourceColumnId] = useState<string | null>(null);
|
|
|
|
// --- Drag and Drop Logic ---
|
|
|
|
const handleDragStart = (e: React.DragEvent, taskId: string, columnId: string) => {
|
|
setDraggedTaskId(taskId);
|
|
setDraggedSourceColumnId(columnId);
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
};
|
|
|
|
const handleDragOver = (e: React.DragEvent) => {
|
|
e.preventDefault(); // Necessary to allow dropping
|
|
e.dataTransfer.dropEffect = 'move';
|
|
};
|
|
|
|
const handleDrop = (e: React.DragEvent, targetColumnId: string) => {
|
|
e.preventDefault();
|
|
if (!draggedTaskId || !draggedSourceColumnId) return;
|
|
|
|
if (draggedSourceColumnId === targetColumnId) {
|
|
setDraggedTaskId(null);
|
|
setDraggedSourceColumnId(null);
|
|
return;
|
|
}
|
|
|
|
// Move logic
|
|
const sourceCol = columns.find(c => c.id === draggedSourceColumnId);
|
|
const targetCol = columns.find(c => c.id === targetColumnId);
|
|
const taskToMove = sourceCol?.tasks.find(t => t.id === draggedTaskId);
|
|
|
|
if (sourceCol && targetCol && taskToMove) {
|
|
const newColumns = columns.map(col => {
|
|
if (col.id === draggedSourceColumnId) {
|
|
return { ...col, tasks: col.tasks.filter(t => t.id !== draggedTaskId) };
|
|
}
|
|
if (col.id === targetColumnId) {
|
|
return { ...col, tasks: [...col.tasks, taskToMove] };
|
|
}
|
|
return col;
|
|
});
|
|
setColumns(newColumns);
|
|
|
|
// INTEGRAÇÃO FINANCEIRA: Se moveu para "Concluído" (done), tem valor e cliente, sugere faturar
|
|
if (targetColumnId === 'done' && taskToMove.value && taskToMove.value > 0 && taskToMove.clientId) {
|
|
const client = companies.find(c => c.id === taskToMove.clientId);
|
|
|
|
// Use toast with action instead of window.confirm for better UI?
|
|
// For now, simpler confirmation but using toast for success
|
|
if (window.confirm(`A tarefa "${taskToMove.title}" foi concluída.\n\nDeseja gerar automaticamente uma Conta a Receber no valor de R$ ${taskToMove.value.toLocaleString('pt-BR')}?`)) {
|
|
|
|
const newReceivable: Receivable = {
|
|
id: Math.random().toString(36).substr(2, 9),
|
|
description: taskToMove.title,
|
|
companyName: client?.fantasyName || client?.name || 'Cliente Kanban',
|
|
category: 'Serviços', // Default category
|
|
value: taskToMove.value,
|
|
dueDate: taskToMove.dueDate || new Date().toISOString().split('T')[0],
|
|
status: 'pending',
|
|
type: 'one-time'
|
|
};
|
|
|
|
onAddReceivable(newReceivable);
|
|
addToast({ type: 'success', title: 'Faturamento Gerado', message: `Conta a receber criada para ${client?.fantasyName}.` });
|
|
}
|
|
}
|
|
}
|
|
|
|
setDraggedTaskId(null);
|
|
setDraggedSourceColumnId(null);
|
|
};
|
|
|
|
// --- CRUD Logic ---
|
|
|
|
const openNewTaskModal = () => {
|
|
setCurrentTask({ priority: 'medium', dueDate: new Date().toISOString().split('T')[0], value: 0, description: '' });
|
|
setCurrentColumnId(columns[0].id); // Default to first column
|
|
setIsTaskModalOpen(true);
|
|
};
|
|
|
|
const openEditTaskModal = (task: KanbanTask, colId: string) => {
|
|
setCurrentTask(task);
|
|
setCurrentColumnId(colId);
|
|
setIsTaskModalOpen(true);
|
|
};
|
|
|
|
const handleSaveTask = () => {
|
|
if (!currentTask.title) {
|
|
addToast({ type: 'warning', title: 'Título Obrigatório', message: 'Dê um nome para a tarefa.' });
|
|
return;
|
|
}
|
|
|
|
// Check if updating existing or creating new
|
|
if (currentTask.id) {
|
|
// Logic to update existing task (potentially moving columns)
|
|
const newColumns = columns.map(col => {
|
|
// Remove from all columns first (in case it moved)
|
|
const filteredTasks = col.tasks.filter(t => t.id !== currentTask.id);
|
|
|
|
// If this is the target column, add the updated task
|
|
if (col.id === currentColumnId) {
|
|
return { ...col, tasks: [...filteredTasks, currentTask as KanbanTask] };
|
|
}
|
|
return { ...col, tasks: filteredTasks };
|
|
});
|
|
setColumns(newColumns);
|
|
addToast({ type: 'success', title: 'Tarefa Atualizada' });
|
|
} else {
|
|
// Create new
|
|
const newTask: KanbanTask = {
|
|
...currentTask,
|
|
id: Math.random().toString(36).substr(2, 9),
|
|
} as KanbanTask;
|
|
|
|
const newColumns = columns.map(col => {
|
|
if (col.id === currentColumnId) {
|
|
return { ...col, tasks: [newTask, ...col.tasks] }; // Add to top
|
|
}
|
|
return col;
|
|
});
|
|
setColumns(newColumns);
|
|
addToast({ type: 'success', title: 'Tarefa Criada' });
|
|
}
|
|
setIsTaskModalOpen(false);
|
|
};
|
|
|
|
const handleDeleteTask = () => {
|
|
if (!currentTask.id) return;
|
|
if (window.confirm('Tem certeza que deseja excluir esta tarefa?')) {
|
|
const newColumns = columns.map(col => ({
|
|
...col,
|
|
tasks: col.tasks.filter(t => t.id !== currentTask.id)
|
|
}));
|
|
setColumns(newColumns);
|
|
setIsTaskModalOpen(false);
|
|
addToast({ type: 'info', title: 'Tarefa Excluída' });
|
|
}
|
|
};
|
|
|
|
const handleCreateColumn = () => {
|
|
if (!newColumnTitle) return;
|
|
const newCol: KanbanColumn = {
|
|
id: Math.random().toString(36).substr(2, 9),
|
|
title: newColumnTitle,
|
|
tasks: []
|
|
};
|
|
setColumns([...columns, newCol]);
|
|
setNewColumnTitle('');
|
|
setIsColumnModalOpen(false);
|
|
addToast({ type: 'success', title: 'Coluna Adicionada' });
|
|
};
|
|
|
|
// Styles for Inputs (High Contrast)
|
|
const labelClass = "block text-sm font-bold text-slate-800 mb-1";
|
|
const inputClass = "w-full p-2.5 bg-white border border-slate-300 rounded-xl text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 outline-none transition-shadow";
|
|
|
|
return (
|
|
<div className="h-full flex flex-col animate-fade-in relative">
|
|
|
|
{/* Task Modal (Create & Edit) */}
|
|
{isTaskModalOpen && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
<div className="absolute inset-0 bg-slate-900/60 backdrop-blur-sm" onClick={() => setIsTaskModalOpen(false)}></div>
|
|
<div className="relative bg-white rounded-2xl shadow-2xl w-full max-w-lg p-0 animate-scale-up flex flex-col max-h-[90vh]">
|
|
<div className="flex justify-between items-center px-6 py-4 border-b border-slate-100 bg-slate-50 flex-shrink-0">
|
|
<h3 className="font-bold text-slate-800 text-lg">
|
|
{currentTask.id ? 'Detalhes da Tarefa' : 'Nova Tarefa'}
|
|
</h3>
|
|
<div className="flex items-center gap-2">
|
|
{currentTask.id && (
|
|
<button onClick={handleDeleteTask} className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors">
|
|
<Trash2 size={18} />
|
|
</button>
|
|
)}
|
|
<button onClick={() => setIsTaskModalOpen(false)} className="p-2 text-slate-400 hover:text-slate-600 rounded-lg transition-colors">
|
|
<X size={20} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="p-6 space-y-5 overflow-y-auto">
|
|
{/* Title */}
|
|
<div>
|
|
<label className={labelClass}>Título da Tarefa</label>
|
|
<input
|
|
type="text"
|
|
className={inputClass}
|
|
value={currentTask.title || ''}
|
|
onChange={e => setCurrentTask({...currentTask, title: e.target.value})}
|
|
placeholder="Ex: Reunião com Cliente X"
|
|
/>
|
|
</div>
|
|
|
|
{/* Status & Priority Row */}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className={labelClass}>Coluna (Status)</label>
|
|
<CustomSelect
|
|
value={currentColumnId}
|
|
onChange={(val) => setCurrentColumnId(val)}
|
|
options={columns.map(col => ({ value: col.id, label: col.title }))}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className={labelClass}>Prioridade</label>
|
|
<CustomSelect
|
|
value={currentTask.priority || 'medium'}
|
|
onChange={(val) => setCurrentTask({...currentTask, priority: val})}
|
|
options={[
|
|
{ value: 'low', label: 'Baixa' },
|
|
{ value: 'medium', label: 'Média' },
|
|
{ value: 'high', label: 'Alta' }
|
|
]}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* CRM Linkage */}
|
|
<div>
|
|
<label className={labelClass}>Vincular Cliente (CRM)</label>
|
|
<CustomSelect
|
|
value={currentTask.clientId || ''}
|
|
onChange={(val) => setCurrentTask({...currentTask, clientId: val})}
|
|
placeholder="Sem vínculo"
|
|
icon={<Building2 size={16}/>}
|
|
options={[
|
|
{ value: '', label: 'Sem vínculo' },
|
|
...companies.map(c => ({ value: c.id, label: c.fantasyName || c.name }))
|
|
]}
|
|
/>
|
|
<p className="text-[10px] text-slate-400 mt-1">Ao concluir a tarefa, o sistema oferecerá gerar cobrança para este cliente.</p>
|
|
</div>
|
|
|
|
{/* Commercial Data Section */}
|
|
<div className="bg-slate-50 p-4 rounded-xl border border-slate-100 space-y-4">
|
|
<h4 className="font-bold text-slate-700 text-sm border-b border-slate-200 pb-2 mb-2 flex items-center gap-2">
|
|
Dados Comerciais & Agenda
|
|
</h4>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className={labelClass}>Valor da Negociação</label>
|
|
<div className="relative">
|
|
<DollarSign size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
|
|
<input
|
|
type="number"
|
|
className={`${inputClass} pl-9`}
|
|
value={currentTask.value || ''}
|
|
onChange={e => setCurrentTask({...currentTask, value: Number(e.target.value)})}
|
|
placeholder="0,00"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className={labelClass}>Prazo / Data</label>
|
|
<div className="relative">
|
|
<Clock size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
|
|
<input
|
|
type="date"
|
|
className={`${inputClass} pl-10`}
|
|
value={currentTask.dueDate || ''}
|
|
onChange={e => setCurrentTask({...currentTask, dueDate: e.target.value})}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className={labelClass}>Descrição / Notas</label>
|
|
<textarea
|
|
rows={4}
|
|
className={inputClass}
|
|
value={currentTask.description || ''}
|
|
onChange={e => setCurrentTask({...currentTask, description: e.target.value})}
|
|
placeholder="Detalhes sobre a negociação, pauta da reunião, etc..."
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<div className="px-6 py-4 bg-slate-50 border-t border-slate-100 flex justify-end gap-3 flex-shrink-0 rounded-b-2xl">
|
|
<button onClick={() => setIsTaskModalOpen(false)} className="px-4 py-2 text-slate-600 font-medium hover:bg-slate-200 rounded-xl transition-colors">
|
|
Cancelar
|
|
</button>
|
|
<button
|
|
onClick={handleSaveTask}
|
|
className="px-6 py-2 bg-primary-500 text-white font-bold rounded-xl hover:bg-primary-600 shadow-lg shadow-primary-200/50 transition-all"
|
|
>
|
|
{currentTask.id ? 'Salvar Alterações' : 'Criar Tarefa'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* New Column Modal */}
|
|
{isColumnModalOpen && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
<div className="absolute inset-0 bg-slate-900/60 backdrop-blur-sm" onClick={() => setIsColumnModalOpen(false)}></div>
|
|
<div className="relative bg-white rounded-2xl shadow-xl w-full max-w-sm p-6 animate-scale-up">
|
|
<h3 className="font-bold text-slate-800 text-lg mb-4">Nova Coluna</h3>
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className={labelClass}>Nome da Coluna</label>
|
|
<input
|
|
type="text"
|
|
className={inputClass}
|
|
value={newColumnTitle}
|
|
onChange={e => setNewColumnTitle(e.target.value)}
|
|
placeholder="Ex: Em Aprovação"
|
|
/>
|
|
</div>
|
|
<div className="flex justify-end gap-2 pt-2">
|
|
<button onClick={() => setIsColumnModalOpen(false)} className="px-4 py-2 text-slate-500 font-medium hover:bg-slate-100 rounded-xl">Cancelar</button>
|
|
<button onClick={handleCreateColumn} className="px-4 py-2 bg-primary-500 text-white font-bold rounded-xl hover:bg-primary-600">Criar</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Kanban Header */}
|
|
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-6">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-slate-800">Projetos & Tarefas</h1>
|
|
<p className="text-slate-500">Gestão visual do fluxo de trabalho.</p>
|
|
</div>
|
|
<button onClick={openNewTaskModal} className="flex items-center gap-2 px-5 py-3 bg-primary-500 text-white rounded-xl shadow-lg shadow-primary-200/50 hover:bg-primary-600 font-bold transition-all">
|
|
<Plus size={20} /> Nova Tarefa
|
|
</button>
|
|
</div>
|
|
|
|
{/* Kanban Board Area */}
|
|
<div className="flex-1 overflow-x-auto">
|
|
<div className="flex gap-6 h-full pb-4">
|
|
{columns.map(col => {
|
|
// Calculate total value for the column
|
|
const totalValue = col.tasks.reduce((sum, task) => sum + (task.value || 0), 0);
|
|
|
|
return (
|
|
<div
|
|
key={col.id}
|
|
className="flex-shrink-0 flex flex-col w-[300px]"
|
|
onDragOver={handleDragOver}
|
|
onDrop={(e) => handleDrop(e, col.id)}
|
|
>
|
|
{/* Column Header */}
|
|
<div className="flex justify-between items-start mb-4 p-1">
|
|
<div>
|
|
<h3 className="font-bold text-slate-700 flex items-center gap-2 truncate">
|
|
{col.title}
|
|
<span className="bg-slate-200 text-slate-600 text-[10px] px-2 py-0.5 rounded-full">{col.tasks.length}</span>
|
|
</h3>
|
|
{totalValue > 0 && (
|
|
<div className="text-xs font-bold text-slate-400 mt-1 pl-1">
|
|
Total: R$ {totalValue.toLocaleString('pt-BR')}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<button className="text-slate-400 hover:text-slate-600"><MoreHorizontal size={18} /></button>
|
|
</div>
|
|
|
|
{/* Tasks Container */}
|
|
<div className="bg-slate-100/50 rounded-2xl p-2 flex-1 border border-slate-100/50 overflow-y-auto">
|
|
<div className="space-y-3 min-h-[50px]">
|
|
{col.tasks.map(task => (
|
|
<div
|
|
key={task.id}
|
|
draggable
|
|
onDragStart={(e) => handleDragStart(e, task.id, col.id)}
|
|
onClick={() => openEditTaskModal(task, col.id)}
|
|
className={`bg-white p-4 rounded-xl shadow-sm border border-slate-100 hover:shadow-md hover:border-primary-200 cursor-pointer transition-all group relative ${draggedTaskId === task.id ? 'opacity-50 border-dashed border-slate-400' : ''}`}
|
|
>
|
|
<div className="flex justify-between items-start mb-2">
|
|
<span className={`text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded ${
|
|
task.priority === 'high' ? 'bg-red-50 text-red-600' :
|
|
task.priority === 'medium' ? 'bg-amber-50 text-amber-600' :
|
|
'bg-blue-50 text-blue-600'
|
|
}`}>
|
|
{task.priority === 'high' ? 'Alta' : task.priority === 'medium' ? 'Média' : 'Baixa'}
|
|
</span>
|
|
<div className="opacity-0 group-hover:opacity-100 text-slate-300 cursor-grab active:cursor-grabbing">
|
|
<GripVertical size={16}/>
|
|
</div>
|
|
</div>
|
|
|
|
<h4 className="font-bold text-slate-800 text-sm mb-3 line-clamp-2">{task.title}</h4>
|
|
|
|
{/* Optional: Show Value if present */}
|
|
{task.value && task.value > 0 && (
|
|
<div className="mb-3 text-xs font-semibold text-slate-600 bg-slate-50 px-2 py-1 rounded inline-block">
|
|
R$ {task.value.toLocaleString('pt-BR')}
|
|
</div>
|
|
)}
|
|
|
|
{/* Show Linked Client Badge */}
|
|
{task.clientId && (
|
|
<div className="mb-3 flex items-center gap-1 text-[10px] text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded w-fit">
|
|
<Building2 size={10} /> {companies.find(c => c.id === task.clientId)?.fantasyName || 'Cliente'}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex justify-between items-center pt-3 border-t border-slate-50">
|
|
<div className="flex -space-x-2">
|
|
<div className="w-6 h-6 rounded-full bg-slate-200 border-2 border-white flex items-center justify-center text-[10px] font-bold text-slate-500">
|
|
<UserCircle size={16} />
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-1 text-xs text-slate-400">
|
|
<Clock size={12} /> {task.dueDate ? new Date(task.dueDate).toLocaleDateString('pt-BR', {day:'2-digit', month:'short'}) : 'Sem data'}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
<button onClick={openNewTaskModal} className="w-full py-2 text-slate-400 text-sm font-medium hover:bg-slate-200/50 rounded-xl transition-colors flex items-center justify-center gap-2 border border-transparent hover:border-slate-200/50">
|
|
<Plus size={16} /> Adicionar
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{/* Add Column Button */}
|
|
<div className="flex-shrink-0 w-[50px] pt-10">
|
|
<button
|
|
onClick={() => setIsColumnModalOpen(true)}
|
|
className="w-full h-[50px] bg-white border border-dashed border-slate-300 rounded-2xl flex items-center justify-center text-slate-400 hover:text-primary-500 hover:border-primary-300 hover:bg-primary-50 transition-all group tooltip-container"
|
|
title="Nova Coluna"
|
|
>
|
|
<Plus size={24} />
|
|
</button>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|