create-varity-app 2.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +85 -0
  3. package/dist/create.js +141 -0
  4. package/dist/index.js +45 -0
  5. package/dist/utils.js +29 -0
  6. package/package.json +61 -0
  7. package/template/.env.example +17 -0
  8. package/template/KNOWN_ISSUES.md +69 -0
  9. package/template/LICENSE +21 -0
  10. package/template/README.md +241 -0
  11. package/template/gitignore +42 -0
  12. package/template/next-env.d.ts +6 -0
  13. package/template/next.config.js +21 -0
  14. package/template/package.json +39 -0
  15. package/template/postcss.config.js +6 -0
  16. package/template/public/logo.svg +4 -0
  17. package/template/public/robots.txt +4 -0
  18. package/template/public/sitemap.xml +4 -0
  19. package/template/src/app/dashboard/layout.tsx +298 -0
  20. package/template/src/app/dashboard/page.tsx +209 -0
  21. package/template/src/app/dashboard/projects/page.tsx +638 -0
  22. package/template/src/app/dashboard/settings/page.tsx +749 -0
  23. package/template/src/app/dashboard/tasks/page.tsx +301 -0
  24. package/template/src/app/dashboard/team/page.tsx +295 -0
  25. package/template/src/app/globals.css +177 -0
  26. package/template/src/app/icon.svg +4 -0
  27. package/template/src/app/layout.tsx +33 -0
  28. package/template/src/app/login/page.tsx +98 -0
  29. package/template/src/app/not-found.tsx +20 -0
  30. package/template/src/app/page.tsx +23 -0
  31. package/template/src/components/dashboard/DashboardStats.tsx +137 -0
  32. package/template/src/components/dashboard/RecentActivity.tsx +63 -0
  33. package/template/src/components/landing/CTA.tsx +42 -0
  34. package/template/src/components/landing/Features.tsx +116 -0
  35. package/template/src/components/landing/Hero.tsx +146 -0
  36. package/template/src/components/landing/HowItWorks.tsx +80 -0
  37. package/template/src/components/landing/Pricing.tsx +124 -0
  38. package/template/src/components/landing/Testimonials.tsx +78 -0
  39. package/template/src/components/providers.tsx +11 -0
  40. package/template/src/components/shared/Footer.tsx +71 -0
  41. package/template/src/components/shared/Navbar.tsx +87 -0
  42. package/template/src/lib/constants.ts +35 -0
  43. package/template/src/lib/database.ts +7 -0
  44. package/template/src/lib/hooks.ts +331 -0
  45. package/template/src/lib/utils.ts +68 -0
  46. package/template/src/lib/varity.ts +1 -0
  47. package/template/src/services/dashboardService.ts +589 -0
  48. package/template/src/types/index.ts +52 -0
  49. package/template/tailwind.config.js +27 -0
  50. package/template/tsconfig.json +23 -0
  51. package/template/varity.config.json +14 -0
@@ -0,0 +1,638 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { KPICard, DataTable, EmptyState } from '@varity-labs/ui-kit';
5
+ import { useProjects, useTasks, useCurrentUser } from '@/lib/hooks';
6
+ import { Plus, FolderKanban, ArrowLeft, ListTodo, CheckCircle, Clock, Users, Pencil, Trash2, Download } from 'lucide-react';
7
+ import {
8
+ Button,
9
+ Input,
10
+ Textarea,
11
+ Select,
12
+ Dialog,
13
+ ConfirmDialog,
14
+ useToast,
15
+ ProjectStatusBadge,
16
+ TaskStatusBadge,
17
+ PriorityBadge
18
+ } from '@varity-labs/ui-kit';
19
+ import { formatDate, downloadCSV } from '@/lib/utils';
20
+ import { PRIORITY_OPTIONS, PROJECT_STATUS_OPTIONS, TASK_STATUS_OPTIONS } from '@/lib/constants';
21
+ import type { Project, Task } from '@/types';
22
+
23
+ const EMPTY_PROJECT = { name: '', description: '', dueDate: '', status: 'active' as Project['status'] };
24
+ const EMPTY_TASK = { title: '', description: '', priority: 'medium' as Task['priority'], status: 'todo' as Task['status'] };
25
+
26
+ export default function ProjectsPage() {
27
+ const toast = useToast();
28
+ const { email } = useCurrentUser();
29
+ const { data: projects, loading, error, create, update, remove, refresh } = useProjects();
30
+
31
+ // Selected project for detail view
32
+ const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
33
+
34
+ // Create project dialog
35
+ const [createDialogOpen, setCreateDialogOpen] = useState(false);
36
+ const [projectForm, setProjectForm] = useState(EMPTY_PROJECT);
37
+ const [nameError, setNameError] = useState('');
38
+ const [projectSubmitting, setProjectSubmitting] = useState(false);
39
+
40
+ // Edit project dialog
41
+ const [editingProject, setEditingProject] = useState<Project | null>(null);
42
+ const [editForm, setEditForm] = useState(EMPTY_PROJECT);
43
+ const [editNameError, setEditNameError] = useState('');
44
+ const [editSubmitting, setEditSubmitting] = useState(false);
45
+
46
+ // Delete project confirmation
47
+ const [deletingProjectId, setDeletingProjectId] = useState<string | null>(null);
48
+ const [deleteSubmitting, setDeleteSubmitting] = useState(false);
49
+
50
+ // Create task dialog (for detail view)
51
+ const [taskDialogOpen, setTaskDialogOpen] = useState(false);
52
+ const [taskForm, setTaskForm] = useState(EMPTY_TASK);
53
+ const [titleError, setTitleError] = useState('');
54
+ const [taskSubmitting, setTaskSubmitting] = useState(false);
55
+
56
+ // Edit task dialog (for detail view)
57
+ const [editingTask, setEditingTask] = useState<Task | null>(null);
58
+ const [editTaskForm, setEditTaskForm] = useState(EMPTY_TASK);
59
+ const [editTitleError, setEditTitleError] = useState('');
60
+ const [editTaskSubmitting, setEditTaskSubmitting] = useState(false);
61
+
62
+ // Delete task confirmation (for detail view)
63
+ const [deletingTaskId, setDeletingTaskId] = useState<string | null>(null);
64
+ const [deleteTaskSubmitting, setDeleteTaskSubmitting] = useState(false);
65
+
66
+ const selectedProject = selectedProjectId
67
+ ? projects.find((p) => p.id === selectedProjectId)
68
+ : null;
69
+
70
+ // Tasks for the selected project
71
+ const {
72
+ data: tasks,
73
+ loading: tasksLoading,
74
+ create: createTask,
75
+ update: updateTask,
76
+ remove: removeTask,
77
+ } = useTasks(selectedProjectId || undefined);
78
+
79
+ const deletingProject = deletingProjectId
80
+ ? projects.find((p) => p.id === deletingProjectId)
81
+ : null;
82
+
83
+ // --- Project CRUD handlers ---
84
+
85
+ function resetCreateDialog() {
86
+ setProjectForm(EMPTY_PROJECT);
87
+ setNameError('');
88
+ setCreateDialogOpen(false);
89
+ }
90
+
91
+ function startEditProject(project: Project) {
92
+ setEditingProject(project);
93
+ setEditForm({
94
+ name: project.name,
95
+ description: project.description,
96
+ dueDate: project.dueDate ? project.dueDate.split('T')[0] : '',
97
+ status: project.status,
98
+ });
99
+ setEditNameError('');
100
+ }
101
+
102
+ function resetEditDialog() {
103
+ setEditingProject(null);
104
+ setEditForm(EMPTY_PROJECT);
105
+ setEditNameError('');
106
+ }
107
+
108
+ async function handleCreateProject() {
109
+ if (!projectForm.name.trim()) {
110
+ setNameError('Project name is required');
111
+ return;
112
+ }
113
+
114
+ setProjectSubmitting(true);
115
+ try {
116
+ await create({
117
+ name: projectForm.name.trim(),
118
+ description: projectForm.description,
119
+ status: 'active',
120
+ owner: email,
121
+ members: [email],
122
+ dueDate: projectForm.dueDate || new Date(Date.now() + 30 * 86400000).toISOString(),
123
+ });
124
+ toast.success('Project created successfully');
125
+ resetCreateDialog();
126
+ } catch {
127
+ toast.error('Failed to create project. Please try again.');
128
+ } finally {
129
+ setProjectSubmitting(false);
130
+ }
131
+ }
132
+
133
+ async function handleEditProject() {
134
+ if (!editingProject?.id) return;
135
+ if (!editForm.name.trim()) {
136
+ setEditNameError('Project name is required');
137
+ return;
138
+ }
139
+
140
+ setEditSubmitting(true);
141
+ try {
142
+ await update(editingProject.id, {
143
+ name: editForm.name.trim(),
144
+ description: editForm.description,
145
+ status: editForm.status,
146
+ dueDate: editForm.dueDate || editingProject.dueDate,
147
+ });
148
+ toast.success('Project updated successfully');
149
+ resetEditDialog();
150
+ } catch {
151
+ toast.error('Failed to update project. Please try again.');
152
+ } finally {
153
+ setEditSubmitting(false);
154
+ }
155
+ }
156
+
157
+ async function handleDeleteProject() {
158
+ if (!deletingProjectId) return;
159
+
160
+ setDeleteSubmitting(true);
161
+ try {
162
+ await remove(deletingProjectId);
163
+ toast.success('Project deleted');
164
+ setDeletingProjectId(null);
165
+ if (selectedProjectId === deletingProjectId) {
166
+ setSelectedProjectId(null);
167
+ }
168
+ } catch {
169
+ toast.error('Failed to delete project. Please try again.');
170
+ } finally {
171
+ setDeleteSubmitting(false);
172
+ }
173
+ }
174
+
175
+ // --- Task CRUD handlers ---
176
+
177
+ function resetTaskDialog() {
178
+ setTaskForm(EMPTY_TASK);
179
+ setTitleError('');
180
+ setTaskDialogOpen(false);
181
+ }
182
+
183
+ function startEditTask(task: Task) {
184
+ setEditingTask(task);
185
+ setEditTaskForm({
186
+ title: task.title,
187
+ description: task.description || '',
188
+ priority: task.priority,
189
+ status: task.status,
190
+ });
191
+ setEditTitleError('');
192
+ }
193
+
194
+ function resetEditTaskDialog() {
195
+ setEditingTask(null);
196
+ setEditTaskForm(EMPTY_TASK);
197
+ setEditTitleError('');
198
+ }
199
+
200
+ async function handleCreateTask() {
201
+ if (!taskForm.title.trim() || !selectedProjectId) {
202
+ setTitleError('Task title is required');
203
+ return;
204
+ }
205
+
206
+ setTaskSubmitting(true);
207
+ try {
208
+ await createTask({
209
+ projectId: selectedProjectId,
210
+ title: taskForm.title.trim(),
211
+ description: taskForm.description,
212
+ status: 'todo',
213
+ priority: taskForm.priority,
214
+ });
215
+ toast.success('Task added successfully');
216
+ resetTaskDialog();
217
+ } catch {
218
+ toast.error('Failed to add task. Please try again.');
219
+ } finally {
220
+ setTaskSubmitting(false);
221
+ }
222
+ }
223
+
224
+ async function handleEditTask() {
225
+ if (!editingTask?.id) return;
226
+ if (!editTaskForm.title.trim()) {
227
+ setEditTitleError('Task title is required');
228
+ return;
229
+ }
230
+
231
+ setEditTaskSubmitting(true);
232
+ try {
233
+ await updateTask(editingTask.id, {
234
+ title: editTaskForm.title.trim(),
235
+ description: editTaskForm.description,
236
+ priority: editTaskForm.priority,
237
+ status: editTaskForm.status,
238
+ });
239
+ toast.success('Task updated successfully');
240
+ resetEditTaskDialog();
241
+ } catch {
242
+ toast.error('Failed to update task. Please try again.');
243
+ } finally {
244
+ setEditTaskSubmitting(false);
245
+ }
246
+ }
247
+
248
+ async function handleDeleteTask() {
249
+ if (!deletingTaskId) return;
250
+
251
+ setDeleteTaskSubmitting(true);
252
+ try {
253
+ await removeTask(deletingTaskId);
254
+ toast.success('Task deleted');
255
+ setDeletingTaskId(null);
256
+ } catch {
257
+ toast.error('Failed to delete task. Please try again.');
258
+ } finally {
259
+ setDeleteTaskSubmitting(false);
260
+ }
261
+ }
262
+
263
+ // --- Table columns ---
264
+
265
+ const projectColumns = [
266
+ { key: 'name', header: 'Project Name', sortable: true },
267
+ {
268
+ key: 'status',
269
+ header: 'Status',
270
+ render: (value: string) => <ProjectStatusBadge status={value} />,
271
+ },
272
+ {
273
+ key: 'members',
274
+ header: 'Members',
275
+ render: (value: string[]) => {
276
+ const count = value?.filter(Boolean).length || 0;
277
+ return <span>{count} {count === 1 ? 'member' : 'members'}</span>;
278
+ },
279
+ },
280
+ {
281
+ key: 'dueDate',
282
+ header: 'Due Date',
283
+ sortable: true,
284
+ render: (value: string) => formatDate(value),
285
+ },
286
+ {
287
+ key: 'id',
288
+ header: '',
289
+ render: (_: string, row: Project) => (
290
+ <div className="flex gap-1" onClick={(e) => e.stopPropagation()}>
291
+ <button
292
+ onClick={() => startEditProject(row)}
293
+ className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600 transition-colors"
294
+ title="Edit project"
295
+ >
296
+ <Pencil className="h-4 w-4" />
297
+ </button>
298
+ <button
299
+ onClick={() => setDeletingProjectId(row.id!)}
300
+ className="rounded p-1 text-gray-400 hover:bg-red-50 hover:text-red-600 transition-colors"
301
+ title="Delete project"
302
+ >
303
+ <Trash2 className="h-4 w-4" />
304
+ </button>
305
+ </div>
306
+ ),
307
+ },
308
+ ];
309
+
310
+ const taskColumns = [
311
+ { key: 'title', header: 'Task', sortable: true },
312
+ {
313
+ key: 'status',
314
+ header: 'Status',
315
+ render: (value: string) => <TaskStatusBadge status={value} />,
316
+ },
317
+ {
318
+ key: 'priority',
319
+ header: 'Priority',
320
+ render: (value: string) => <PriorityBadge priority={value} />,
321
+ },
322
+ {
323
+ key: 'assignee',
324
+ header: 'Assignee',
325
+ render: (value: string) => value || 'Unassigned',
326
+ },
327
+ {
328
+ key: 'id',
329
+ header: '',
330
+ render: (_: string, row: Task) => (
331
+ <div className="flex gap-1">
332
+ <button
333
+ onClick={() => startEditTask(row)}
334
+ className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600 transition-colors"
335
+ title="Edit task"
336
+ >
337
+ <Pencil className="h-4 w-4" />
338
+ </button>
339
+ <button
340
+ onClick={() => setDeletingTaskId(row.id!)}
341
+ className="rounded p-1 text-gray-400 hover:bg-red-50 hover:text-red-600 transition-colors"
342
+ title="Delete task"
343
+ >
344
+ <Trash2 className="h-4 w-4" />
345
+ </button>
346
+ </div>
347
+ ),
348
+ },
349
+ ];
350
+
351
+ // --- Shared dialogs ---
352
+
353
+ const editProjectDialog = (
354
+ <Dialog
355
+ open={!!editingProject}
356
+ onClose={resetEditDialog}
357
+ title="Edit Project"
358
+ description="Update your project details."
359
+ >
360
+ <div className="space-y-4">
361
+ <Input
362
+ label="Project Name"
363
+ required
364
+ value={editForm.name}
365
+ onChange={(e) => { setEditForm({ ...editForm, name: e.target.value }); if (editNameError) setEditNameError(''); }}
366
+ error={editNameError}
367
+ placeholder="Enter project name"
368
+ />
369
+ <Textarea
370
+ label="Description"
371
+ value={editForm.description}
372
+ onChange={(e) => setEditForm({ ...editForm, description: e.target.value })}
373
+ rows={3}
374
+ placeholder="What is this project about?"
375
+ />
376
+ <Select
377
+ label="Status"
378
+ value={editForm.status}
379
+ onChange={(e) => setEditForm({ ...editForm, status: e.target.value as Project['status'] })}
380
+ options={[...PROJECT_STATUS_OPTIONS]}
381
+ />
382
+ <Input
383
+ label="Due Date"
384
+ type="date"
385
+ value={editForm.dueDate}
386
+ onChange={(e) => setEditForm({ ...editForm, dueDate: e.target.value })}
387
+ />
388
+ <div className="flex gap-2 pt-2">
389
+ <Button onClick={handleEditProject} loading={editSubmitting}>Save Changes</Button>
390
+ <Button variant="secondary" onClick={resetEditDialog} disabled={editSubmitting}>Cancel</Button>
391
+ </div>
392
+ </div>
393
+ </Dialog>
394
+ );
395
+
396
+ const deleteProjectDialog = (
397
+ <ConfirmDialog
398
+ open={!!deletingProjectId}
399
+ onClose={() => setDeletingProjectId(null)}
400
+ onConfirm={handleDeleteProject}
401
+ title="Delete Project"
402
+ description={`Are you sure you want to delete "${deletingProject?.name || ''}"? This will remove the project and all its tasks. This action cannot be undone.`}
403
+ confirmLabel="Delete Project"
404
+ loading={deleteSubmitting}
405
+ />
406
+ );
407
+
408
+ const editTaskDialog = (
409
+ <Dialog
410
+ open={!!editingTask}
411
+ onClose={resetEditTaskDialog}
412
+ title="Edit Task"
413
+ description="Update task details."
414
+ >
415
+ <div className="space-y-4">
416
+ <Input
417
+ label="Task Title"
418
+ required
419
+ value={editTaskForm.title}
420
+ onChange={(e) => { setEditTaskForm({ ...editTaskForm, title: e.target.value }); if (editTitleError) setEditTitleError(''); }}
421
+ error={editTitleError}
422
+ placeholder="What needs to be done?"
423
+ />
424
+ <Textarea
425
+ label="Description"
426
+ value={editTaskForm.description}
427
+ onChange={(e) => setEditTaskForm({ ...editTaskForm, description: e.target.value })}
428
+ rows={2}
429
+ placeholder="Add details..."
430
+ />
431
+ <Select
432
+ label="Status"
433
+ value={editTaskForm.status}
434
+ onChange={(e) => setEditTaskForm({ ...editTaskForm, status: e.target.value as Task['status'] })}
435
+ options={[...TASK_STATUS_OPTIONS]}
436
+ />
437
+ <Select
438
+ label="Priority"
439
+ value={editTaskForm.priority}
440
+ onChange={(e) => setEditTaskForm({ ...editTaskForm, priority: e.target.value as Task['priority'] })}
441
+ options={[...PRIORITY_OPTIONS]}
442
+ />
443
+ <div className="flex gap-2 pt-2">
444
+ <Button onClick={handleEditTask} loading={editTaskSubmitting}>Save Changes</Button>
445
+ <Button variant="secondary" onClick={resetEditTaskDialog} disabled={editTaskSubmitting}>Cancel</Button>
446
+ </div>
447
+ </div>
448
+ </Dialog>
449
+ );
450
+
451
+ const deleteTaskDialog = (
452
+ <ConfirmDialog
453
+ open={!!deletingTaskId}
454
+ onClose={() => setDeletingTaskId(null)}
455
+ onConfirm={handleDeleteTask}
456
+ title="Delete Task"
457
+ description="Are you sure you want to delete this task? This action cannot be undone."
458
+ confirmLabel="Delete Task"
459
+ loading={deleteTaskSubmitting}
460
+ />
461
+ );
462
+
463
+ // ---------- PROJECT DETAIL VIEW ----------
464
+ if (selectedProject) {
465
+ const todoCount = tasks.filter((t) => t.status === 'todo').length;
466
+ const inProgressCount = tasks.filter((t) => t.status === 'in_progress').length;
467
+ const doneCount = tasks.filter((t) => t.status === 'done').length;
468
+
469
+ return (
470
+ <div className="space-y-6">
471
+ {editProjectDialog}
472
+ {deleteProjectDialog}
473
+ {editTaskDialog}
474
+ {deleteTaskDialog}
475
+
476
+ <div>
477
+ <Button
478
+ variant="ghost"
479
+ size="sm"
480
+ onClick={() => setSelectedProjectId(null)}
481
+ icon={<ArrowLeft className="h-4 w-4" />}
482
+ className="mb-2"
483
+ >
484
+ Back to Projects
485
+ </Button>
486
+ <div className="flex items-center justify-between">
487
+ <div>
488
+ <h1 className="text-2xl font-bold text-gray-900">{selectedProject.name}</h1>
489
+ <p className="mt-1 text-sm text-gray-600">{selectedProject.description}</p>
490
+ </div>
491
+ <div className="flex items-center gap-2">
492
+ <ProjectStatusBadge status={selectedProject.status} />
493
+ <Button variant="ghost" size="sm" onClick={() => startEditProject(selectedProject)} icon={<Pencil className="h-4 w-4" />}>
494
+ Edit
495
+ </Button>
496
+ <Button variant="ghost" size="sm" onClick={() => setDeletingProjectId(selectedProject.id!)} className="text-red-600 hover:text-red-700 hover:bg-red-50" icon={<Trash2 className="h-4 w-4" />}>
497
+ Delete
498
+ </Button>
499
+ </div>
500
+ </div>
501
+ </div>
502
+
503
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
504
+ <KPICard title="To Do" value={todoCount} icon={<ListTodo className="h-5 w-5" />} />
505
+ <KPICard title="In Progress" value={inProgressCount} icon={<Clock className="h-5 w-5" />} />
506
+ <KPICard title="Completed" value={doneCount} icon={<CheckCircle className="h-5 w-5" />} trend={doneCount > 0 ? 'up' : undefined} />
507
+ <KPICard title="Team Members" value={selectedProject.members?.length || 0} icon={<Users className="h-5 w-5" />} />
508
+ </div>
509
+
510
+ <div className="flex items-center justify-between">
511
+ <h2 className="text-lg font-semibold text-gray-900">Tasks</h2>
512
+ <Button size="sm" onClick={() => setTaskDialogOpen(true)} icon={<Plus className="h-4 w-4" />}>
513
+ Add Task
514
+ </Button>
515
+ </div>
516
+
517
+ <Dialog open={taskDialogOpen} onClose={resetTaskDialog} title="Add Task" description="Create a new task for this project.">
518
+ <div className="space-y-4">
519
+ <Input
520
+ label="Task Title"
521
+ required
522
+ value={taskForm.title}
523
+ onChange={(e) => { setTaskForm({ ...taskForm, title: e.target.value }); if (titleError) setTitleError(''); }}
524
+ error={titleError}
525
+ placeholder="What needs to be done?"
526
+ />
527
+ <Select
528
+ label="Priority"
529
+ value={taskForm.priority}
530
+ onChange={(e) => setTaskForm({ ...taskForm, priority: e.target.value as Task['priority'] })}
531
+ options={[...PRIORITY_OPTIONS]}
532
+ />
533
+ <div className="flex gap-2 pt-2">
534
+ <Button onClick={handleCreateTask} loading={taskSubmitting}>Add Task</Button>
535
+ <Button variant="secondary" onClick={resetTaskDialog} disabled={taskSubmitting}>Cancel</Button>
536
+ </div>
537
+ </div>
538
+ </Dialog>
539
+
540
+ <div className="rounded-xl border border-gray-200 bg-white shadow-sm">
541
+ <DataTable columns={taskColumns} data={tasks} loading={tasksLoading} emptyMessage="No tasks yet. Add a task to get started." />
542
+ </div>
543
+ </div>
544
+ );
545
+ }
546
+
547
+ // ---------- PROJECTS LIST VIEW ----------
548
+ return (
549
+ <div className="space-y-6">
550
+ {editProjectDialog}
551
+ {deleteProjectDialog}
552
+
553
+ <div className="flex items-center justify-between">
554
+ <div>
555
+ <h1 className="text-2xl font-bold text-gray-900">Projects</h1>
556
+ <p className="mt-1 text-sm text-gray-600">Manage your projects and track progress.</p>
557
+ </div>
558
+ <div className="flex items-center gap-2">
559
+ {projects.length > 0 && (
560
+ <Button
561
+ variant="secondary"
562
+ size="sm"
563
+ onClick={() => downloadCSV(
564
+ projects.map((p) => ({ name: p.name, status: p.status, description: p.description || '', dueDate: p.dueDate || '', createdAt: p.createdAt })),
565
+ 'projects.csv'
566
+ )}
567
+ icon={<Download className="h-4 w-4" />}
568
+ >
569
+ Export
570
+ </Button>
571
+ )}
572
+ <Button onClick={() => setCreateDialogOpen(true)} icon={<Plus className="h-4 w-4" />}>
573
+ New Project
574
+ </Button>
575
+ </div>
576
+ </div>
577
+
578
+ <Dialog open={createDialogOpen} onClose={resetCreateDialog} title="Create New Project" description="Add a new project to organize your team's work.">
579
+ <div className="space-y-4">
580
+ <Input
581
+ label="Project Name"
582
+ required
583
+ value={projectForm.name}
584
+ onChange={(e) => { setProjectForm({ ...projectForm, name: e.target.value }); if (nameError) setNameError(''); }}
585
+ error={nameError}
586
+ placeholder="Enter project name"
587
+ />
588
+ <Textarea
589
+ label="Description"
590
+ value={projectForm.description}
591
+ onChange={(e) => setProjectForm({ ...projectForm, description: e.target.value })}
592
+ rows={3}
593
+ placeholder="What is this project about?"
594
+ />
595
+ <Input
596
+ label="Due Date"
597
+ type="date"
598
+ value={projectForm.dueDate}
599
+ onChange={(e) => setProjectForm({ ...projectForm, dueDate: e.target.value })}
600
+ hint="Defaults to 30 days from today if left blank"
601
+ />
602
+ <div className="flex gap-2 pt-2">
603
+ <Button onClick={handleCreateProject} loading={projectSubmitting}>Create Project</Button>
604
+ <Button variant="secondary" onClick={resetCreateDialog} disabled={projectSubmitting}>Cancel</Button>
605
+ </div>
606
+ </div>
607
+ </Dialog>
608
+
609
+ {error && (
610
+ <div className="flex items-center justify-between rounded-lg border border-red-200 bg-red-50 px-4 py-3">
611
+ <p className="text-sm text-red-700">Failed to load projects. Please check your connection and try again.</p>
612
+ <button onClick={refresh} className="text-sm font-medium text-red-700 hover:text-red-800 underline">Retry</button>
613
+ </div>
614
+ )}
615
+
616
+ {!loading && projects.length === 0 && !createDialogOpen ? (
617
+ <EmptyState
618
+ title="No projects yet"
619
+ description="Create your first project to get started."
620
+ icon={<FolderKanban className="h-12 w-12 text-gray-400" />}
621
+ action={{ label: 'Create Project', onClick: () => setCreateDialogOpen(true) }}
622
+ />
623
+ ) : (
624
+ <div className="rounded-xl border border-gray-200 bg-white shadow-sm">
625
+ <DataTable
626
+ columns={projectColumns}
627
+ data={projects}
628
+ loading={loading}
629
+ pagination
630
+ pageSize={10}
631
+ hoverable
632
+ onRowClick={(row) => setSelectedProjectId(row.id)}
633
+ />
634
+ </div>
635
+ )}
636
+ </div>
637
+ );
638
+ }