Skip to content

Commit dcb20bc

Browse files
committed
Phase 241-243: Kanban + Calendar views — 10 tests passing
- PM Task Kanban: drag-drop board grouping tasks by status (todo/in_progress/review/done/cancelled) - PM Task Calendar: month view with tasks plotted by due_date, today highlight - CRM Pipeline Kanban: drag-drop board grouping opportunities by stage with revenue display - PATCH move-status and move-stage endpoints for optimistic UI drag-drop updates - Routes added before resource declarations to avoid route-model binding conflicts https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 8bb20db commit dcb20bc

9 files changed

Lines changed: 683 additions & 0 deletions

File tree

erp/app/Modules/CRM/Http/Controllers/CrmLeadController.php

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,4 +152,38 @@ public function convert(CrmLead $lead): RedirectResponse
152152

153153
return redirect()->route('crm.leads.show', $lead)->with('success', 'Converted to opportunity.');
154154
}
155+
156+
public function kanban(Request $request): Response
157+
{
158+
$stages = \App\Modules\CRM\Models\CrmStage::orderBy('sequence')->get(['id', 'name', 'color']);
159+
$leads = CrmLead::with(['stage', 'assignee'])
160+
->where('type', 'opportunity')
161+
->whereNotIn('status', ['lost'])
162+
->get()
163+
->groupBy('stage_id');
164+
165+
$columns = $stages->map(fn ($stage) => [
166+
'id' => $stage->id,
167+
'name' => $stage->name,
168+
'color' => $stage->color ?? '#6b7280',
169+
'leads' => ($leads->get($stage->id) ?? collect())->map(fn ($l) => [
170+
'id' => $l->id,
171+
'title' => $l->title,
172+
'contact_name' => $l->contact_name,
173+
'expected_revenue' => $l->expected_revenue,
174+
'probability' => $l->probability,
175+
'priority' => $l->priority,
176+
'assignee' => $l->assignee ? ['name' => $l->assignee->name] : null,
177+
])->values(),
178+
]);
179+
180+
return Inertia::render('CRM/Pipeline/Kanban', ['columns' => $columns]);
181+
}
182+
183+
public function moveStage(Request $request, CrmLead $lead): \Illuminate\Http\JsonResponse
184+
{
185+
$data = $request->validate(['stage_id' => 'required|exists:crm_stages,id']);
186+
$lead->update(['stage_id' => $data['stage_id']]);
187+
return response()->json(['ok' => true]);
188+
}
155189
}

erp/app/Modules/CRM/routes/crm.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@
1616
Route::delete('stages/{stage}', [CrmStageController::class, 'destroy'])->name('stages.destroy');
1717
Route::get('stages', [CrmStageController::class, 'index'])->name('stages.index');
1818

19+
// Pipeline Kanban (before leads resource)
20+
Route::get('pipeline/kanban', [CrmLeadController::class, 'kanban'])->name('pipeline.kanban');
21+
Route::patch('leads/{lead}/move-stage', [CrmLeadController::class, 'moveStage'])->name('leads.move-stage');
22+
1923
// Leads — action routes first
2024
Route::post('leads/{lead}/mark-won', [CrmLeadController::class, 'markWon'])->name('leads.mark-won');
2125
Route::post('leads/{lead}/mark-lost', [CrmLeadController::class, 'markLost'])->name('leads.mark-lost');

erp/app/Modules/PM/Http/Controllers/TaskController.php

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,4 +109,64 @@ public function complete(Project $project, Task $task): RedirectResponse
109109

110110
return redirect()->back()->with('success', 'Task marked as done.');
111111
}
112+
113+
public function kanban(Project $project): Response
114+
{
115+
$tasks = $project->tasks()
116+
->with('assignee')
117+
->orderBy('sequence')
118+
->get()
119+
->groupBy('status');
120+
121+
$columns = ['todo', 'in_progress', 'review', 'done', 'cancelled'];
122+
$grouped = [];
123+
foreach ($columns as $col) {
124+
$grouped[$col] = $tasks->get($col, collect())->map(fn ($t) => [
125+
'id' => $t->id,
126+
'title' => $t->title,
127+
'priority' => $t->priority,
128+
'due_date' => $t->due_date?->toDateString(),
129+
'assignee' => $t->assignee ? ['name' => $t->assignee->name] : null,
130+
'is_overdue' => $t->isOverdue(),
131+
])->values();
132+
}
133+
134+
return Inertia::render('PM/Tasks/Kanban', [
135+
'project' => ['id' => $project->id, 'name' => $project->name],
136+
'columns' => $grouped,
137+
]);
138+
}
139+
140+
public function moveStatus(Request $request, Project $project, Task $task): \Illuminate\Http\JsonResponse
141+
{
142+
$data = $request->validate(['status' => 'required|in:todo,in_progress,review,done,cancelled']);
143+
$task->update(['status' => $data['status']]);
144+
return response()->json(['ok' => true]);
145+
}
146+
147+
public function calendar(Request $request, Project $project): Response
148+
{
149+
$year = (int) ($request->year ?? now()->year);
150+
$month = (int) ($request->month ?? now()->month);
151+
152+
$tasks = $project->tasks()
153+
->whereNotNull('due_date')
154+
->whereYear('due_date', $year)
155+
->whereMonth('due_date', $month)
156+
->get(['id', 'title', 'status', 'priority', 'due_date'])
157+
->map(fn ($t) => [
158+
'id' => $t->id,
159+
'title' => $t->title,
160+
'status' => $t->status,
161+
'priority' => $t->priority,
162+
'due_date' => $t->due_date->toDateString(),
163+
]);
164+
165+
return Inertia::render('PM/Tasks/Calendar', [
166+
'project' => ['id' => $project->id, 'name' => $project->name],
167+
'tasks' => $tasks,
168+
'year' => $year,
169+
'month' => $month,
170+
]);
171+
}
112172
}

erp/app/Modules/PM/routes/pm.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@
1313
// Task complete action BEFORE resource
1414
Route::post('projects/{project}/tasks/{task}/complete', [TaskController::class, 'complete'])->name('tasks.complete');
1515

16+
// Kanban and Calendar views (must be before resource routes)
17+
Route::get('projects/{project}/tasks/kanban', [TaskController::class, 'kanban'])->name('projects.tasks.kanban');
18+
Route::patch('projects/{project}/tasks/{task}/move-status', [TaskController::class, 'moveStatus'])->name('projects.tasks.move-status');
19+
Route::get('projects/{project}/tasks/calendar', [TaskController::class, 'calendar'])->name('projects.tasks.calendar');
20+
1621
// Milestone actions
1722
Route::post('projects/{project}/milestones', [MilestoneController::class, 'store'])->name('milestones.store');
1823
Route::post('projects/{project}/milestones/{milestone}/complete', [MilestoneController::class, 'complete'])->name('milestones.complete');
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { Head, Link, router } from '@inertiajs/react';
2+
import AppLayout from '@/Layouts/AppLayout';
3+
import { useState, useRef } from 'react';
4+
5+
interface LeadCard {
6+
id: number;
7+
title: string;
8+
contact_name: string | null;
9+
expected_revenue: number | null;
10+
probability: number | null;
11+
priority: string;
12+
assignee: { name: string } | null;
13+
}
14+
15+
interface Column {
16+
id: number;
17+
name: string;
18+
color: string;
19+
leads: LeadCard[];
20+
}
21+
22+
interface Props {
23+
columns: Column[];
24+
}
25+
26+
const PRIORITY_COLORS: Record<string, string> = {
27+
low: 'bg-slate-100 text-slate-500',
28+
normal: 'bg-blue-100 text-blue-600',
29+
high: 'bg-orange-100 text-orange-600',
30+
urgent: 'bg-red-100 text-red-600',
31+
};
32+
33+
export default function PipelineKanban({ columns: initialColumns }: Props) {
34+
const [columns, setColumns] = useState<Column[]>(initialColumns);
35+
const dragLead = useRef<{ lead: LeadCard; fromColId: number } | null>(null);
36+
37+
function onDragStart(lead: LeadCard, fromColId: number) {
38+
dragLead.current = { lead, fromColId };
39+
}
40+
41+
function onDrop(toColId: number) {
42+
if (!dragLead.current || dragLead.current.fromColId === toColId) return;
43+
const { lead, fromColId } = dragLead.current;
44+
dragLead.current = null;
45+
46+
setColumns(prev => prev.map(col => {
47+
if (col.id === fromColId) return { ...col, leads: col.leads.filter(l => l.id !== lead.id) };
48+
if (col.id === toColId) return { ...col, leads: [...col.leads, lead] };
49+
return col;
50+
}));
51+
52+
router.patch(`/crm/leads/${lead.id}/move-stage`, { stage_id: toColId });
53+
}
54+
55+
return (
56+
<AppLayout>
57+
<Head title="CRM Pipeline — Kanban" />
58+
<div className="p-6">
59+
<div className="mb-4 flex items-center gap-3">
60+
<h1 className="text-xl font-semibold text-slate-800">Pipeline Kanban</h1>
61+
<div className="ml-auto flex gap-2">
62+
<Link href="/crm/leads?type=opportunity" className="rounded-md border border-slate-300 px-3 py-1.5 text-sm hover:bg-slate-50">
63+
List
64+
</Link>
65+
</div>
66+
</div>
67+
68+
<div className="flex gap-4 overflow-x-auto pb-4">
69+
{columns.map(col => (
70+
<div
71+
key={col.id}
72+
className="flex w-72 shrink-0 flex-col rounded-lg border border-slate-200 bg-slate-50 p-3"
73+
onDragOver={e => e.preventDefault()}
74+
onDrop={() => onDrop(col.id)}
75+
>
76+
<div className="mb-3 flex items-center gap-2">
77+
<span className="h-3 w-3 rounded-full" style={{ backgroundColor: col.color }} />
78+
<span className="text-sm font-semibold text-slate-700">{col.name}</span>
79+
<span className="ml-auto rounded-full bg-white px-2 py-0.5 text-xs font-medium text-slate-600 shadow-sm">
80+
{col.leads.length}
81+
</span>
82+
</div>
83+
84+
<div className="flex flex-col gap-2">
85+
{col.leads.map(lead => (
86+
<div
87+
key={lead.id}
88+
draggable
89+
onDragStart={() => onDragStart(lead, col.id)}
90+
className="cursor-grab rounded-md border border-white bg-white p-3 shadow-sm hover:shadow-md transition-shadow active:cursor-grabbing"
91+
>
92+
<Link href={`/crm/leads/${lead.id}`} className="block text-sm font-medium text-slate-800 hover:text-blue-600">
93+
{lead.title}
94+
</Link>
95+
{lead.contact_name && (
96+
<p className="mt-0.5 text-xs text-slate-500">{lead.contact_name}</p>
97+
)}
98+
<div className="mt-2 flex items-center justify-between">
99+
{lead.expected_revenue != null && (
100+
<span className="text-sm font-semibold text-slate-700">
101+
${Number(lead.expected_revenue).toLocaleString()}
102+
</span>
103+
)}
104+
{lead.probability != null && (
105+
<span className="text-xs text-slate-400">{lead.probability}%</span>
106+
)}
107+
</div>
108+
<div className="mt-1.5 flex items-center gap-1">
109+
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${PRIORITY_COLORS[lead.priority] ?? 'bg-slate-100 text-slate-500'}`}>
110+
{lead.priority}
111+
</span>
112+
{lead.assignee && (
113+
<span className="ml-auto text-xs text-slate-400">{lead.assignee.name}</span>
114+
)}
115+
</div>
116+
</div>
117+
))}
118+
</div>
119+
120+
<Link
121+
href={`/crm/leads/create`}
122+
className="mt-3 block rounded-md border border-dashed border-slate-300 py-1.5 text-center text-xs text-slate-400 hover:border-slate-400 hover:text-slate-600"
123+
>
124+
+ Add lead
125+
</Link>
126+
</div>
127+
))}
128+
</div>
129+
</div>
130+
</AppLayout>
131+
);
132+
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { Head, Link, router } from '@inertiajs/react';
2+
import AppLayout from '@/Layouts/AppLayout';
3+
4+
interface Task {
5+
id: number;
6+
title: string;
7+
status: string;
8+
priority: string | null;
9+
due_date: string;
10+
}
11+
12+
interface Props {
13+
project: { id: number; name: string };
14+
tasks: Task[];
15+
year: number;
16+
month: number;
17+
}
18+
19+
const STATUS_COLORS: Record<string, string> = {
20+
todo: 'bg-slate-200 text-slate-700',
21+
in_progress: 'bg-blue-100 text-blue-700',
22+
review: 'bg-yellow-100 text-yellow-700',
23+
done: 'bg-green-100 text-green-700',
24+
cancelled: 'bg-red-100 text-red-600',
25+
};
26+
27+
export default function TaskCalendar({ project, tasks, year, month }: Props) {
28+
const firstDay = new Date(year, month - 1, 1);
29+
const daysInMonth = new Date(year, month, 0).getDate();
30+
const startDow = firstDay.getDay(); // 0=Sun
31+
32+
const tasksByDate: Record<string, Task[]> = {};
33+
for (const task of tasks) {
34+
if (!tasksByDate[task.due_date]) tasksByDate[task.due_date] = [];
35+
tasksByDate[task.due_date].push(task);
36+
}
37+
38+
function navMonth(delta: number) {
39+
let m = month + delta;
40+
let y = year;
41+
if (m > 12) { m = 1; y++; }
42+
if (m < 1) { m = 12; y--; }
43+
router.get(`/pm/projects/${project.id}/tasks/calendar`, { year: y, month: m }, { preserveState: false });
44+
}
45+
46+
const monthName = firstDay.toLocaleString('default', { month: 'long', year: 'numeric' });
47+
const today = new Date().toISOString().slice(0, 10);
48+
49+
const cells: (number | null)[] = Array(startDow).fill(null);
50+
for (let d = 1; d <= daysInMonth; d++) cells.push(d);
51+
while (cells.length % 7 !== 0) cells.push(null);
52+
53+
return (
54+
<AppLayout>
55+
<Head title={`Calendar — ${project.name}`} />
56+
<div className="p-6">
57+
<div className="mb-4 flex items-center gap-3">
58+
<Link href={`/pm/projects/${project.id}`} className="text-sm text-slate-500 hover:text-slate-700">
59+
{project.name}
60+
</Link>
61+
<span className="text-slate-400">/</span>
62+
<h1 className="text-xl font-semibold text-slate-800">Task Calendar</h1>
63+
<div className="ml-auto flex gap-2">
64+
<Link href={`/pm/projects/${project.id}/tasks`} className="rounded-md border border-slate-300 px-3 py-1.5 text-sm hover:bg-slate-50">List</Link>
65+
<Link href={`/pm/projects/${project.id}/tasks/kanban`} className="rounded-md border border-slate-300 px-3 py-1.5 text-sm hover:bg-slate-50">Kanban</Link>
66+
</div>
67+
</div>
68+
69+
<div className="rounded-lg border border-slate-200 bg-white shadow-sm">
70+
<div className="flex items-center border-b border-slate-200 px-4 py-3">
71+
<button onClick={() => navMonth(-1)} className="rounded p-1 hover:bg-slate-100"></button>
72+
<span className="mx-4 text-base font-semibold text-slate-800">{monthName}</span>
73+
<button onClick={() => navMonth(1)} className="rounded p-1 hover:bg-slate-100"></button>
74+
</div>
75+
76+
<div className="grid grid-cols-7">
77+
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map(d => (
78+
<div key={d} className="border-b border-slate-200 px-2 py-2 text-center text-xs font-medium text-slate-500">
79+
{d}
80+
</div>
81+
))}
82+
83+
{cells.map((day, i) => {
84+
if (!day) return <div key={i} className="min-h-[80px] border-b border-r border-slate-100 bg-slate-50" />;
85+
const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
86+
const dayTasks = tasksByDate[dateStr] ?? [];
87+
const isToday = dateStr === today;
88+
89+
return (
90+
<div key={i} className={`min-h-[80px] border-b border-r border-slate-100 p-1.5 ${isToday ? 'bg-blue-50' : ''}`}>
91+
<span className={`mb-1 inline-flex h-6 w-6 items-center justify-center rounded-full text-xs font-medium ${isToday ? 'bg-blue-600 text-white' : 'text-slate-600'}`}>
92+
{day}
93+
</span>
94+
{dayTasks.slice(0, 3).map(t => (
95+
<Link
96+
key={t.id}
97+
href={`/pm/projects/${project.id}/tasks/${t.id}`}
98+
className={`mb-0.5 block truncate rounded px-1 py-0.5 text-xs ${STATUS_COLORS[t.status] ?? 'bg-slate-100 text-slate-600'}`}
99+
>
100+
{t.title}
101+
</Link>
102+
))}
103+
{dayTasks.length > 3 && (
104+
<p className="text-xs text-slate-400">+{dayTasks.length - 3} more</p>
105+
)}
106+
</div>
107+
);
108+
})}
109+
</div>
110+
</div>
111+
</div>
112+
</AppLayout>
113+
);
114+
}

0 commit comments

Comments
 (0)