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.
- package/LICENSE +21 -0
- package/README.md +85 -0
- package/dist/create.js +141 -0
- package/dist/index.js +45 -0
- package/dist/utils.js +29 -0
- package/package.json +61 -0
- package/template/.env.example +17 -0
- package/template/KNOWN_ISSUES.md +69 -0
- package/template/LICENSE +21 -0
- package/template/README.md +241 -0
- package/template/gitignore +42 -0
- package/template/next-env.d.ts +6 -0
- package/template/next.config.js +21 -0
- package/template/package.json +39 -0
- package/template/postcss.config.js +6 -0
- package/template/public/logo.svg +4 -0
- package/template/public/robots.txt +4 -0
- package/template/public/sitemap.xml +4 -0
- package/template/src/app/dashboard/layout.tsx +298 -0
- package/template/src/app/dashboard/page.tsx +209 -0
- package/template/src/app/dashboard/projects/page.tsx +638 -0
- package/template/src/app/dashboard/settings/page.tsx +749 -0
- package/template/src/app/dashboard/tasks/page.tsx +301 -0
- package/template/src/app/dashboard/team/page.tsx +295 -0
- package/template/src/app/globals.css +177 -0
- package/template/src/app/icon.svg +4 -0
- package/template/src/app/layout.tsx +33 -0
- package/template/src/app/login/page.tsx +98 -0
- package/template/src/app/not-found.tsx +20 -0
- package/template/src/app/page.tsx +23 -0
- package/template/src/components/dashboard/DashboardStats.tsx +137 -0
- package/template/src/components/dashboard/RecentActivity.tsx +63 -0
- package/template/src/components/landing/CTA.tsx +42 -0
- package/template/src/components/landing/Features.tsx +116 -0
- package/template/src/components/landing/Hero.tsx +146 -0
- package/template/src/components/landing/HowItWorks.tsx +80 -0
- package/template/src/components/landing/Pricing.tsx +124 -0
- package/template/src/components/landing/Testimonials.tsx +78 -0
- package/template/src/components/providers.tsx +11 -0
- package/template/src/components/shared/Footer.tsx +71 -0
- package/template/src/components/shared/Navbar.tsx +87 -0
- package/template/src/lib/constants.ts +35 -0
- package/template/src/lib/database.ts +7 -0
- package/template/src/lib/hooks.ts +331 -0
- package/template/src/lib/utils.ts +68 -0
- package/template/src/lib/varity.ts +1 -0
- package/template/src/services/dashboardService.ts +589 -0
- package/template/src/types/index.ts +52 -0
- package/template/tailwind.config.js +27 -0
- package/template/tsconfig.json +23 -0
- package/template/varity.config.json +14 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { DataTable, EmptyState } from '@varity-labs/ui-kit';
|
|
5
|
+
import { useTasks, useProjects } from '@/lib/hooks';
|
|
6
|
+
import {
|
|
7
|
+
useToast,
|
|
8
|
+
Button,
|
|
9
|
+
Input,
|
|
10
|
+
Textarea,
|
|
11
|
+
Select,
|
|
12
|
+
Dialog,
|
|
13
|
+
ConfirmDialog,
|
|
14
|
+
TaskStatusBadge,
|
|
15
|
+
PriorityBadge
|
|
16
|
+
} from '@varity-labs/ui-kit';
|
|
17
|
+
import { formatDateShort, downloadCSV } from '@/lib/utils';
|
|
18
|
+
import { TASK_STATUS_OPTIONS, PRIORITY_OPTIONS } from '@/lib/constants';
|
|
19
|
+
import { ListTodo, Pencil, Trash2, Download } from 'lucide-react';
|
|
20
|
+
import type { Task } from '@/types';
|
|
21
|
+
|
|
22
|
+
const STATUS_CYCLE: Record<string, Task['status']> = {
|
|
23
|
+
todo: 'in_progress',
|
|
24
|
+
in_progress: 'done',
|
|
25
|
+
done: 'todo',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const FILTER_OPTIONS = [
|
|
29
|
+
{ value: 'all', label: 'All' },
|
|
30
|
+
...TASK_STATUS_OPTIONS,
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const EMPTY_EDIT = { title: '', description: '', priority: 'medium' as Task['priority'], status: 'todo' as Task['status'] };
|
|
34
|
+
|
|
35
|
+
export default function TasksPage() {
|
|
36
|
+
const { data: tasks, loading, error, update, remove, refresh } = useTasks();
|
|
37
|
+
const { data: projects } = useProjects();
|
|
38
|
+
const toast = useToast();
|
|
39
|
+
const [statusFilter, setStatusFilter] = useState<string>('all');
|
|
40
|
+
|
|
41
|
+
// Edit task dialog
|
|
42
|
+
const [editingTask, setEditingTask] = useState<Task | null>(null);
|
|
43
|
+
const [editForm, setEditForm] = useState(EMPTY_EDIT);
|
|
44
|
+
const [editTitleError, setEditTitleError] = useState('');
|
|
45
|
+
const [editSubmitting, setEditSubmitting] = useState(false);
|
|
46
|
+
|
|
47
|
+
// Delete task confirmation
|
|
48
|
+
const [deletingTaskId, setDeletingTaskId] = useState<string | null>(null);
|
|
49
|
+
const [deleteSubmitting, setDeleteSubmitting] = useState(false);
|
|
50
|
+
|
|
51
|
+
const filteredTasks =
|
|
52
|
+
statusFilter === 'all'
|
|
53
|
+
? tasks
|
|
54
|
+
: tasks.filter((t) => t.status === statusFilter);
|
|
55
|
+
|
|
56
|
+
function getProjectName(projectId: string): string {
|
|
57
|
+
const project = projects.find((p) => p.id === projectId);
|
|
58
|
+
return project?.name || 'Unknown';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function cycleStatus(task: Task): Promise<void> {
|
|
62
|
+
if (!task.id) return;
|
|
63
|
+
try {
|
|
64
|
+
await update(task.id, { status: STATUS_CYCLE[task.status] });
|
|
65
|
+
toast.success(`Task marked as ${STATUS_CYCLE[task.status].replace('_', ' ')}`);
|
|
66
|
+
} catch {
|
|
67
|
+
toast.error('Failed to update task status');
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function startEditTask(task: Task) {
|
|
72
|
+
setEditingTask(task);
|
|
73
|
+
setEditForm({
|
|
74
|
+
title: task.title,
|
|
75
|
+
description: task.description || '',
|
|
76
|
+
priority: task.priority,
|
|
77
|
+
status: task.status,
|
|
78
|
+
});
|
|
79
|
+
setEditTitleError('');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function resetEditDialog() {
|
|
83
|
+
setEditingTask(null);
|
|
84
|
+
setEditForm(EMPTY_EDIT);
|
|
85
|
+
setEditTitleError('');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function handleEditTask() {
|
|
89
|
+
if (!editingTask?.id) return;
|
|
90
|
+
if (!editForm.title.trim()) {
|
|
91
|
+
setEditTitleError('Task title is required');
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
setEditSubmitting(true);
|
|
96
|
+
try {
|
|
97
|
+
await update(editingTask.id, {
|
|
98
|
+
title: editForm.title.trim(),
|
|
99
|
+
description: editForm.description,
|
|
100
|
+
priority: editForm.priority,
|
|
101
|
+
status: editForm.status,
|
|
102
|
+
});
|
|
103
|
+
toast.success('Task updated successfully');
|
|
104
|
+
resetEditDialog();
|
|
105
|
+
} catch {
|
|
106
|
+
toast.error('Failed to update task. Please try again.');
|
|
107
|
+
} finally {
|
|
108
|
+
setEditSubmitting(false);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function handleDeleteTask() {
|
|
113
|
+
if (!deletingTaskId) return;
|
|
114
|
+
|
|
115
|
+
setDeleteSubmitting(true);
|
|
116
|
+
try {
|
|
117
|
+
await remove(deletingTaskId);
|
|
118
|
+
toast.success('Task deleted');
|
|
119
|
+
setDeletingTaskId(null);
|
|
120
|
+
} catch {
|
|
121
|
+
toast.error('Failed to delete task. Please try again.');
|
|
122
|
+
} finally {
|
|
123
|
+
setDeleteSubmitting(false);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const columns = [
|
|
128
|
+
{ key: 'title', header: 'Task', sortable: true },
|
|
129
|
+
{
|
|
130
|
+
key: 'projectId',
|
|
131
|
+
header: 'Project',
|
|
132
|
+
render: (value: string) => (
|
|
133
|
+
<span className="text-gray-600">{getProjectName(value)}</span>
|
|
134
|
+
),
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
key: 'status',
|
|
138
|
+
header: 'Status',
|
|
139
|
+
render: (value: string, row: Task) => (
|
|
140
|
+
<button onClick={() => cycleStatus(row)} title="Click to change status">
|
|
141
|
+
<TaskStatusBadge status={value} />
|
|
142
|
+
</button>
|
|
143
|
+
),
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
key: 'priority',
|
|
147
|
+
header: 'Priority',
|
|
148
|
+
sortable: true,
|
|
149
|
+
render: (value: string) => <PriorityBadge priority={value || 'medium'} />,
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
key: 'assignee',
|
|
153
|
+
header: 'Assignee',
|
|
154
|
+
render: (value: string) => value || 'Unassigned',
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
key: 'createdAt',
|
|
158
|
+
header: 'Created',
|
|
159
|
+
sortable: true,
|
|
160
|
+
render: (value: string) => formatDateShort(value),
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
key: 'id',
|
|
164
|
+
header: '',
|
|
165
|
+
render: (_: string, row: Task) => (
|
|
166
|
+
<div className="flex gap-1">
|
|
167
|
+
<button
|
|
168
|
+
onClick={() => startEditTask(row)}
|
|
169
|
+
className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600 transition-colors"
|
|
170
|
+
title="Edit task"
|
|
171
|
+
>
|
|
172
|
+
<Pencil className="h-4 w-4" />
|
|
173
|
+
</button>
|
|
174
|
+
<button
|
|
175
|
+
onClick={() => setDeletingTaskId(row.id!)}
|
|
176
|
+
className="rounded p-1 text-gray-400 hover:bg-red-50 hover:text-red-600 transition-colors"
|
|
177
|
+
title="Delete task"
|
|
178
|
+
>
|
|
179
|
+
<Trash2 className="h-4 w-4" />
|
|
180
|
+
</button>
|
|
181
|
+
</div>
|
|
182
|
+
),
|
|
183
|
+
},
|
|
184
|
+
];
|
|
185
|
+
|
|
186
|
+
return (
|
|
187
|
+
<div className="space-y-6">
|
|
188
|
+
<Dialog
|
|
189
|
+
open={!!editingTask}
|
|
190
|
+
onClose={resetEditDialog}
|
|
191
|
+
title="Edit Task"
|
|
192
|
+
description="Update task details."
|
|
193
|
+
>
|
|
194
|
+
<div className="space-y-4">
|
|
195
|
+
<Input
|
|
196
|
+
label="Task Title"
|
|
197
|
+
required
|
|
198
|
+
value={editForm.title}
|
|
199
|
+
onChange={(e) => { setEditForm({ ...editForm, title: e.target.value }); if (editTitleError) setEditTitleError(''); }}
|
|
200
|
+
error={editTitleError}
|
|
201
|
+
placeholder="What needs to be done?"
|
|
202
|
+
/>
|
|
203
|
+
<Textarea
|
|
204
|
+
label="Description"
|
|
205
|
+
value={editForm.description}
|
|
206
|
+
onChange={(e) => setEditForm({ ...editForm, description: e.target.value })}
|
|
207
|
+
rows={2}
|
|
208
|
+
placeholder="Add details..."
|
|
209
|
+
/>
|
|
210
|
+
<Select
|
|
211
|
+
label="Status"
|
|
212
|
+
value={editForm.status}
|
|
213
|
+
onChange={(e) => setEditForm({ ...editForm, status: e.target.value as Task['status'] })}
|
|
214
|
+
options={[...TASK_STATUS_OPTIONS]}
|
|
215
|
+
/>
|
|
216
|
+
<Select
|
|
217
|
+
label="Priority"
|
|
218
|
+
value={editForm.priority}
|
|
219
|
+
onChange={(e) => setEditForm({ ...editForm, priority: e.target.value as Task['priority'] })}
|
|
220
|
+
options={[...PRIORITY_OPTIONS]}
|
|
221
|
+
/>
|
|
222
|
+
<div className="flex gap-2 pt-2">
|
|
223
|
+
<Button onClick={handleEditTask} loading={editSubmitting}>Save Changes</Button>
|
|
224
|
+
<Button variant="secondary" onClick={resetEditDialog} disabled={editSubmitting}>Cancel</Button>
|
|
225
|
+
</div>
|
|
226
|
+
</div>
|
|
227
|
+
</Dialog>
|
|
228
|
+
|
|
229
|
+
<ConfirmDialog
|
|
230
|
+
open={!!deletingTaskId}
|
|
231
|
+
onClose={() => setDeletingTaskId(null)}
|
|
232
|
+
onConfirm={handleDeleteTask}
|
|
233
|
+
title="Delete Task"
|
|
234
|
+
description="Are you sure you want to delete this task? This action cannot be undone."
|
|
235
|
+
confirmLabel="Delete Task"
|
|
236
|
+
loading={deleteSubmitting}
|
|
237
|
+
/>
|
|
238
|
+
|
|
239
|
+
<div className="flex items-center justify-between">
|
|
240
|
+
<div>
|
|
241
|
+
<h1 className="text-2xl font-bold text-gray-900">Tasks</h1>
|
|
242
|
+
<p className="mt-1 text-sm text-gray-600">
|
|
243
|
+
All tasks across your projects. Click status to update.
|
|
244
|
+
</p>
|
|
245
|
+
</div>
|
|
246
|
+
{tasks.length > 0 && (
|
|
247
|
+
<Button
|
|
248
|
+
variant="secondary"
|
|
249
|
+
size="sm"
|
|
250
|
+
onClick={() => downloadCSV(
|
|
251
|
+
tasks.map((t) => ({ title: t.title, status: t.status, priority: t.priority, description: t.description || '', createdAt: t.createdAt })),
|
|
252
|
+
'tasks.csv'
|
|
253
|
+
)}
|
|
254
|
+
icon={<Download className="h-4 w-4" />}
|
|
255
|
+
>
|
|
256
|
+
Export
|
|
257
|
+
</Button>
|
|
258
|
+
)}
|
|
259
|
+
</div>
|
|
260
|
+
|
|
261
|
+
{error && (
|
|
262
|
+
<div className="flex items-center justify-between rounded-lg border border-red-200 bg-red-50 px-4 py-3">
|
|
263
|
+
<p className="text-sm text-red-700">Failed to load tasks. Please check your connection and try again.</p>
|
|
264
|
+
<button onClick={refresh} className="text-sm font-medium text-red-700 hover:text-red-800 underline">Retry</button>
|
|
265
|
+
</div>
|
|
266
|
+
)}
|
|
267
|
+
|
|
268
|
+
<div className="flex gap-2">
|
|
269
|
+
{FILTER_OPTIONS.map((option) => (
|
|
270
|
+
<Button
|
|
271
|
+
key={option.value}
|
|
272
|
+
variant={statusFilter === option.value ? 'primary' : 'secondary'}
|
|
273
|
+
size="sm"
|
|
274
|
+
onClick={() => setStatusFilter(option.value)}
|
|
275
|
+
>
|
|
276
|
+
{option.label}
|
|
277
|
+
</Button>
|
|
278
|
+
))}
|
|
279
|
+
</div>
|
|
280
|
+
|
|
281
|
+
{!loading && tasks.length === 0 ? (
|
|
282
|
+
<EmptyState
|
|
283
|
+
title="No tasks yet"
|
|
284
|
+
description="Tasks will appear here when you add them to projects."
|
|
285
|
+
icon={<ListTodo className="h-12 w-12 text-gray-400" />}
|
|
286
|
+
/>
|
|
287
|
+
) : (
|
|
288
|
+
<div className="rounded-xl border border-gray-200 bg-white shadow-sm">
|
|
289
|
+
<DataTable
|
|
290
|
+
columns={columns}
|
|
291
|
+
data={filteredTasks}
|
|
292
|
+
loading={loading}
|
|
293
|
+
pagination
|
|
294
|
+
pageSize={15}
|
|
295
|
+
hoverable
|
|
296
|
+
/>
|
|
297
|
+
</div>
|
|
298
|
+
)}
|
|
299
|
+
</div>
|
|
300
|
+
);
|
|
301
|
+
}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { DataTable, EmptyState } from '@varity-labs/ui-kit';
|
|
5
|
+
import { useTeam } from '@/lib/hooks';
|
|
6
|
+
import { Plus, Users, Pencil, Trash2 } from 'lucide-react';
|
|
7
|
+
import {
|
|
8
|
+
Button,
|
|
9
|
+
Input,
|
|
10
|
+
Select,
|
|
11
|
+
Dialog,
|
|
12
|
+
ConfirmDialog,
|
|
13
|
+
useToast,
|
|
14
|
+
RoleBadge
|
|
15
|
+
} from '@varity-labs/ui-kit';
|
|
16
|
+
import { formatDate, isValidEmail } from '@/lib/utils';
|
|
17
|
+
import { ROLE_OPTIONS } from '@/lib/constants';
|
|
18
|
+
import type { TeamMember } from '@/types';
|
|
19
|
+
|
|
20
|
+
const EMPTY_FORM = { name: '', email: '', role: 'member' as TeamMember['role'] };
|
|
21
|
+
|
|
22
|
+
export default function TeamPage() {
|
|
23
|
+
const toast = useToast();
|
|
24
|
+
const { data: team, loading, error, create, update, remove, refresh } = useTeam();
|
|
25
|
+
|
|
26
|
+
// Invite dialog
|
|
27
|
+
const [dialogOpen, setDialogOpen] = useState(false);
|
|
28
|
+
const [formData, setFormData] = useState(EMPTY_FORM);
|
|
29
|
+
const [errors, setErrors] = useState<{ name?: string; email?: string }>({});
|
|
30
|
+
const [submitting, setSubmitting] = useState(false);
|
|
31
|
+
|
|
32
|
+
// Edit role dialog
|
|
33
|
+
const [editingMember, setEditingMember] = useState<TeamMember | null>(null);
|
|
34
|
+
const [editRole, setEditRole] = useState<TeamMember['role']>('member');
|
|
35
|
+
const [editSubmitting, setEditSubmitting] = useState(false);
|
|
36
|
+
|
|
37
|
+
// Remove member confirmation
|
|
38
|
+
const [removingMemberId, setRemovingMemberId] = useState<string | null>(null);
|
|
39
|
+
const [removeSubmitting, setRemoveSubmitting] = useState(false);
|
|
40
|
+
|
|
41
|
+
const removingMember = removingMemberId
|
|
42
|
+
? team.find((m) => m.id === removingMemberId)
|
|
43
|
+
: null;
|
|
44
|
+
|
|
45
|
+
function resetAndClose() {
|
|
46
|
+
setFormData(EMPTY_FORM);
|
|
47
|
+
setErrors({});
|
|
48
|
+
setDialogOpen(false);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function startEditMember(member: TeamMember) {
|
|
52
|
+
setEditingMember(member);
|
|
53
|
+
setEditRole(member.role);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function resetEditDialog() {
|
|
57
|
+
setEditingMember(null);
|
|
58
|
+
setEditRole('member');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function validateForm(): boolean {
|
|
62
|
+
const newErrors: { name?: string; email?: string } = {};
|
|
63
|
+
|
|
64
|
+
if (!formData.name.trim()) {
|
|
65
|
+
newErrors.name = 'Name is required';
|
|
66
|
+
}
|
|
67
|
+
if (!formData.email.trim()) {
|
|
68
|
+
newErrors.email = 'Email is required';
|
|
69
|
+
} else if (!isValidEmail(formData.email.trim())) {
|
|
70
|
+
newErrors.email = 'Please enter a valid email address';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
setErrors(newErrors);
|
|
74
|
+
return Object.keys(newErrors).length === 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function handleInvite() {
|
|
78
|
+
if (!validateForm()) return;
|
|
79
|
+
|
|
80
|
+
setSubmitting(true);
|
|
81
|
+
try {
|
|
82
|
+
await create({
|
|
83
|
+
name: formData.name.trim(),
|
|
84
|
+
email: formData.email.trim(),
|
|
85
|
+
role: formData.role,
|
|
86
|
+
});
|
|
87
|
+
toast.success(`${formData.name.trim()} added to team`);
|
|
88
|
+
resetAndClose();
|
|
89
|
+
} catch {
|
|
90
|
+
toast.error('Failed to send invitation. Please try again.');
|
|
91
|
+
} finally {
|
|
92
|
+
setSubmitting(false);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function handleEditRole() {
|
|
97
|
+
if (!editingMember?.id) return;
|
|
98
|
+
|
|
99
|
+
setEditSubmitting(true);
|
|
100
|
+
try {
|
|
101
|
+
await update(editingMember.id, { role: editRole });
|
|
102
|
+
toast.success(`${editingMember.name}'s role updated to ${editRole}`);
|
|
103
|
+
resetEditDialog();
|
|
104
|
+
} catch {
|
|
105
|
+
toast.error('Failed to update role. Please try again.');
|
|
106
|
+
} finally {
|
|
107
|
+
setEditSubmitting(false);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function handleRemoveMember() {
|
|
112
|
+
if (!removingMemberId) return;
|
|
113
|
+
|
|
114
|
+
setRemoveSubmitting(true);
|
|
115
|
+
try {
|
|
116
|
+
await remove(removingMemberId);
|
|
117
|
+
toast.success('Team member removed');
|
|
118
|
+
setRemovingMemberId(null);
|
|
119
|
+
} catch {
|
|
120
|
+
toast.error('Failed to remove member. Please try again.');
|
|
121
|
+
} finally {
|
|
122
|
+
setRemoveSubmitting(false);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const columns = [
|
|
127
|
+
{ key: 'name', header: 'Name', sortable: true },
|
|
128
|
+
{ key: 'email', header: 'Email', sortable: true },
|
|
129
|
+
{
|
|
130
|
+
key: 'role',
|
|
131
|
+
header: 'Role',
|
|
132
|
+
render: (value: string) => <RoleBadge role={value} />,
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
key: 'joinedAt',
|
|
136
|
+
header: 'Joined',
|
|
137
|
+
sortable: true,
|
|
138
|
+
render: (value: string) => formatDate(value),
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
key: 'id',
|
|
142
|
+
header: '',
|
|
143
|
+
render: (_: string, row: TeamMember) => (
|
|
144
|
+
<div className="flex gap-1">
|
|
145
|
+
<button
|
|
146
|
+
onClick={() => startEditMember(row)}
|
|
147
|
+
className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600 transition-colors"
|
|
148
|
+
title="Edit role"
|
|
149
|
+
>
|
|
150
|
+
<Pencil className="h-4 w-4" />
|
|
151
|
+
</button>
|
|
152
|
+
<button
|
|
153
|
+
onClick={() => setRemovingMemberId(row.id!)}
|
|
154
|
+
className="rounded p-1 text-gray-400 hover:bg-red-50 hover:text-red-600 transition-colors"
|
|
155
|
+
title="Remove member"
|
|
156
|
+
>
|
|
157
|
+
<Trash2 className="h-4 w-4" />
|
|
158
|
+
</button>
|
|
159
|
+
</div>
|
|
160
|
+
),
|
|
161
|
+
},
|
|
162
|
+
];
|
|
163
|
+
|
|
164
|
+
return (
|
|
165
|
+
<div className="space-y-6">
|
|
166
|
+
<Dialog
|
|
167
|
+
open={dialogOpen}
|
|
168
|
+
onClose={resetAndClose}
|
|
169
|
+
title="Invite Team Member"
|
|
170
|
+
description="Send an invitation to join your team."
|
|
171
|
+
>
|
|
172
|
+
<div className="space-y-4">
|
|
173
|
+
<Input
|
|
174
|
+
label="Name"
|
|
175
|
+
required
|
|
176
|
+
value={formData.name}
|
|
177
|
+
onChange={(e) => {
|
|
178
|
+
setFormData({ ...formData, name: e.target.value });
|
|
179
|
+
if (errors.name) setErrors({ ...errors, name: undefined });
|
|
180
|
+
}}
|
|
181
|
+
error={errors.name}
|
|
182
|
+
placeholder="Full name"
|
|
183
|
+
/>
|
|
184
|
+
<Input
|
|
185
|
+
label="Email"
|
|
186
|
+
required
|
|
187
|
+
type="email"
|
|
188
|
+
value={formData.email}
|
|
189
|
+
onChange={(e) => {
|
|
190
|
+
setFormData({ ...formData, email: e.target.value });
|
|
191
|
+
if (errors.email) setErrors({ ...errors, email: undefined });
|
|
192
|
+
}}
|
|
193
|
+
error={errors.email}
|
|
194
|
+
placeholder="email@example.com"
|
|
195
|
+
/>
|
|
196
|
+
<Select
|
|
197
|
+
label="Role"
|
|
198
|
+
value={formData.role}
|
|
199
|
+
onChange={(e) =>
|
|
200
|
+
setFormData({ ...formData, role: e.target.value as TeamMember['role'] })
|
|
201
|
+
}
|
|
202
|
+
options={[...ROLE_OPTIONS]}
|
|
203
|
+
/>
|
|
204
|
+
<div className="flex gap-2 pt-2">
|
|
205
|
+
<Button onClick={handleInvite} loading={submitting}>
|
|
206
|
+
Send Invite
|
|
207
|
+
</Button>
|
|
208
|
+
<Button variant="secondary" onClick={resetAndClose} disabled={submitting}>
|
|
209
|
+
Cancel
|
|
210
|
+
</Button>
|
|
211
|
+
</div>
|
|
212
|
+
</div>
|
|
213
|
+
</Dialog>
|
|
214
|
+
|
|
215
|
+
<Dialog
|
|
216
|
+
open={!!editingMember}
|
|
217
|
+
onClose={resetEditDialog}
|
|
218
|
+
title="Edit Role"
|
|
219
|
+
description={editingMember ? `Change ${editingMember.name}'s role.` : ''}
|
|
220
|
+
>
|
|
221
|
+
<div className="space-y-4">
|
|
222
|
+
<div>
|
|
223
|
+
<label className="mb-1 block text-sm font-medium text-gray-700">Member</label>
|
|
224
|
+
<p className="text-sm text-gray-900">{editingMember?.name} ({editingMember?.email})</p>
|
|
225
|
+
</div>
|
|
226
|
+
<Select
|
|
227
|
+
label="Role"
|
|
228
|
+
value={editRole}
|
|
229
|
+
onChange={(e) => setEditRole(e.target.value as TeamMember['role'])}
|
|
230
|
+
options={[...ROLE_OPTIONS]}
|
|
231
|
+
/>
|
|
232
|
+
<div className="flex gap-2 pt-2">
|
|
233
|
+
<Button onClick={handleEditRole} loading={editSubmitting}>Save Changes</Button>
|
|
234
|
+
<Button variant="secondary" onClick={resetEditDialog} disabled={editSubmitting}>Cancel</Button>
|
|
235
|
+
</div>
|
|
236
|
+
</div>
|
|
237
|
+
</Dialog>
|
|
238
|
+
|
|
239
|
+
<ConfirmDialog
|
|
240
|
+
open={!!removingMemberId}
|
|
241
|
+
onClose={() => setRemovingMemberId(null)}
|
|
242
|
+
onConfirm={handleRemoveMember}
|
|
243
|
+
title="Remove Team Member"
|
|
244
|
+
description={`Are you sure you want to remove ${removingMember?.name || 'this member'} from the team? They will lose access to all projects.`}
|
|
245
|
+
confirmLabel="Remove Member"
|
|
246
|
+
loading={removeSubmitting}
|
|
247
|
+
/>
|
|
248
|
+
|
|
249
|
+
{error && (
|
|
250
|
+
<div className="flex items-center justify-between rounded-lg border border-red-200 bg-red-50 px-4 py-3">
|
|
251
|
+
<p className="text-sm text-red-700">Failed to load team data. Please check your connection and try again.</p>
|
|
252
|
+
<button onClick={refresh} className="text-sm font-medium text-red-700 hover:text-red-800 underline">Retry</button>
|
|
253
|
+
</div>
|
|
254
|
+
)}
|
|
255
|
+
|
|
256
|
+
<div className="flex items-center justify-between">
|
|
257
|
+
<div>
|
|
258
|
+
<h1 className="text-2xl font-bold text-gray-900">Team</h1>
|
|
259
|
+
<p className="mt-1 text-sm text-gray-600">
|
|
260
|
+
Manage your team members and roles.
|
|
261
|
+
</p>
|
|
262
|
+
</div>
|
|
263
|
+
<Button
|
|
264
|
+
onClick={() => setDialogOpen(true)}
|
|
265
|
+
icon={<Plus className="h-4 w-4" />}
|
|
266
|
+
>
|
|
267
|
+
Invite Member
|
|
268
|
+
</Button>
|
|
269
|
+
</div>
|
|
270
|
+
|
|
271
|
+
{!loading && team.length === 0 && !dialogOpen ? (
|
|
272
|
+
<EmptyState
|
|
273
|
+
title="No team members yet"
|
|
274
|
+
description="Invite your first team member to start collaborating."
|
|
275
|
+
icon={<Users className="h-12 w-12 text-gray-400" />}
|
|
276
|
+
action={{
|
|
277
|
+
label: 'Invite Member',
|
|
278
|
+
onClick: () => setDialogOpen(true),
|
|
279
|
+
}}
|
|
280
|
+
/>
|
|
281
|
+
) : (
|
|
282
|
+
<div className="rounded-xl border border-gray-200 bg-white shadow-sm">
|
|
283
|
+
<DataTable
|
|
284
|
+
columns={columns}
|
|
285
|
+
data={team}
|
|
286
|
+
loading={loading}
|
|
287
|
+
pagination
|
|
288
|
+
pageSize={10}
|
|
289
|
+
hoverable
|
|
290
|
+
/>
|
|
291
|
+
</div>
|
|
292
|
+
)}
|
|
293
|
+
</div>
|
|
294
|
+
);
|
|
295
|
+
}
|