78b57273e2
- Add password hashing with bcrypt - Add AuthService with proper login - Add password strength validation - Add RBAC middleware (AdminOnly, ManagerOrAdmin) - Add tenant isolation middleware - Update CRM handler with tenant filtering - Add JWT fallback for development mode - Add user context helpers - Build successful
568 lines
19 KiB
TypeScript
568 lines
19 KiB
TypeScript
import { useState, useEffect } from 'react'
|
|
import { Card } from '@/components/ui/Card'
|
|
import { Button } from '@/components/ui/Button'
|
|
import { Badge } from '@/components/ui/Badge'
|
|
import { Skeleton } from '@/components/ui/Skeleton'
|
|
import { useAuthStore } from '@/stores/authStore'
|
|
import {
|
|
Plus,
|
|
MoreHorizontal,
|
|
Calendar,
|
|
User,
|
|
Flag,
|
|
CheckCircle2,
|
|
Circle,
|
|
Clock,
|
|
X,
|
|
GripVertical,
|
|
} from 'lucide-react'
|
|
|
|
interface Task {
|
|
id: string
|
|
title: string
|
|
description: string
|
|
status: 'todo' | 'in_progress' | 'review' | 'done'
|
|
priority: 'low' | 'medium' | 'high' | 'urgent'
|
|
assignee?: string
|
|
due_date?: string
|
|
tags: string[]
|
|
created_at: string
|
|
}
|
|
|
|
interface Project {
|
|
id: string
|
|
name: string
|
|
description: string
|
|
status: string
|
|
progress: number
|
|
tasks: Task[]
|
|
members: string[]
|
|
created_at: string
|
|
}
|
|
|
|
const statusColumns = [
|
|
{ id: 'todo', label: 'Att göra', icon: Circle, color: 'bg-gray-100' },
|
|
{ id: 'in_progress', label: 'Pågående', icon: Clock, color: 'bg-blue-50' },
|
|
{ id: 'review', label: 'Granskning', icon: Flag, color: 'bg-yellow-50' },
|
|
{ id: 'done', label: 'Klart', icon: CheckCircle2, color: 'bg-green-50' },
|
|
]
|
|
|
|
const priorityColors = {
|
|
low: 'bg-gray-100 text-gray-700',
|
|
medium: 'bg-blue-100 text-blue-700',
|
|
high: 'bg-orange-100 text-orange-700',
|
|
urgent: 'bg-red-100 text-red-700',
|
|
}
|
|
|
|
const priorityLabels = {
|
|
low: 'Låg',
|
|
medium: 'Medium',
|
|
high: 'Hög',
|
|
urgent: 'Akut',
|
|
}
|
|
|
|
export function ProjectsPage() {
|
|
const [projects, setProjects] = useState<Project[]>([])
|
|
const [selectedProject, setSelectedProject] = useState<Project | null>(null)
|
|
const [tasks, setTasks] = useState<Task[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState('')
|
|
const [showCreateTask, setShowCreateTask] = useState(false)
|
|
const [showCreateProject, setShowCreateProject] = useState(false)
|
|
const { token } = useAuthStore()
|
|
|
|
const [newTask, setNewTask] = useState({
|
|
title: '',
|
|
description: '',
|
|
priority: 'medium' as const,
|
|
status: 'todo' as const,
|
|
assignee: '',
|
|
due_date: '',
|
|
tags: '',
|
|
})
|
|
|
|
const [newProject, setNewProject] = useState({
|
|
name: '',
|
|
description: '',
|
|
})
|
|
|
|
const fetchProjects = async () => {
|
|
setLoading(true)
|
|
try {
|
|
const res = await fetch('/api/projects')
|
|
if (!res.ok) throw new Error('Failed to fetch projects')
|
|
const data = await res.json()
|
|
setProjects(data)
|
|
if (data.length > 0 && !selectedProject) {
|
|
setSelectedProject(data[0])
|
|
fetchTasks(data[0].id)
|
|
}
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to load')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const fetchTasks = async (projectId: string) => {
|
|
try {
|
|
const res = await fetch(`/api/issues?project_id=${projectId}`)
|
|
if (!res.ok) throw new Error('Failed to fetch tasks')
|
|
const data = await res.json()
|
|
// Map issues to tasks format
|
|
const mappedTasks: Task[] = data.map((issue: any) => ({
|
|
id: issue.id,
|
|
title: issue.summary || issue.title || 'Untitled',
|
|
description: issue.description || '',
|
|
status: mapIssueStatus(issue.status),
|
|
priority: mapIssuePriority(issue.priority),
|
|
assignee: issue.assignee,
|
|
due_date: issue.due_date,
|
|
tags: issue.tags || [],
|
|
created_at: issue.created_at,
|
|
}))
|
|
setTasks(mappedTasks)
|
|
} catch (err) {
|
|
console.error('Failed to fetch tasks:', err)
|
|
}
|
|
}
|
|
|
|
const mapIssueStatus = (status: string): Task['status'] => {
|
|
switch (status) {
|
|
case 'backlog':
|
|
case 'todo':
|
|
return 'todo'
|
|
case 'in_progress':
|
|
return 'in_progress'
|
|
case 'review':
|
|
return 'review'
|
|
case 'done':
|
|
case 'resolved':
|
|
case 'closed':
|
|
return 'done'
|
|
default:
|
|
return 'todo'
|
|
}
|
|
}
|
|
|
|
const mapIssuePriority = (priority: string): Task['priority'] => {
|
|
switch (priority) {
|
|
case 'low':
|
|
return 'low'
|
|
case 'medium':
|
|
return 'medium'
|
|
case 'high':
|
|
return 'high'
|
|
case 'urgent':
|
|
case 'critical':
|
|
return 'urgent'
|
|
default:
|
|
return 'medium'
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
fetchProjects()
|
|
}, [])
|
|
|
|
const createTask = async () => {
|
|
if (!newTask.title || !selectedProject) return
|
|
try {
|
|
const res = await fetch('/api/issues', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
summary: newTask.title,
|
|
description: newTask.description,
|
|
priority: newTask.priority,
|
|
issue_type: 'task',
|
|
project_id: selectedProject.id,
|
|
}),
|
|
})
|
|
if (!res.ok) throw new Error('Failed to create')
|
|
setShowCreateTask(false)
|
|
setNewTask({
|
|
title: '',
|
|
description: '',
|
|
priority: 'medium',
|
|
status: 'todo',
|
|
assignee: '',
|
|
due_date: '',
|
|
tags: '',
|
|
})
|
|
fetchTasks(selectedProject.id)
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to create')
|
|
}
|
|
}
|
|
|
|
const createProject = async () => {
|
|
if (!newProject.name) return
|
|
try {
|
|
// Projects API might not exist yet, create locally
|
|
const project: Project = {
|
|
id: `proj-${Date.now()}`,
|
|
name: newProject.name,
|
|
description: newProject.description,
|
|
status: 'active',
|
|
progress: 0,
|
|
tasks: [],
|
|
members: [],
|
|
created_at: new Date().toISOString(),
|
|
}
|
|
setProjects([...projects, project])
|
|
setSelectedProject(project)
|
|
setShowCreateProject(false)
|
|
setNewProject({ name: '', description: '' })
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to create')
|
|
}
|
|
}
|
|
|
|
const updateTaskStatus = async (taskId: string, newStatus: string) => {
|
|
try {
|
|
const res = await fetch(`/api/issues/${taskId}/status`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ status: newStatus }),
|
|
})
|
|
if (res.ok && selectedProject) {
|
|
fetchTasks(selectedProject.id)
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to update:', err)
|
|
}
|
|
}
|
|
|
|
const getTasksByStatus = (status: string) => {
|
|
return tasks.filter((task) => task.status === status)
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="space-y-8">
|
|
<Skeleton className="h-12" />
|
|
<div className="grid grid-cols-4 gap-4">
|
|
{Array.from({ length: 4 }).map((_, i) => (
|
|
<Skeleton key={i} className="h-[500px]" />
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
|
<div>
|
|
<h1 className="text-xl font-semibold text-text-primary">Projekt</h1>
|
|
<p className="text-sm text-text-secondary mt-0.5">
|
|
{selectedProject ? selectedProject.name : 'Välj ett projekt'}
|
|
</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="secondary"
|
|
icon={<Plus size={16} />}
|
|
onClick={() => setShowCreateProject(true)}
|
|
>
|
|
Nytt projekt
|
|
</Button>
|
|
<Button
|
|
icon={<Plus size={16} />}
|
|
onClick={() => setShowCreateTask(true)}
|
|
disabled={!selectedProject}
|
|
>
|
|
Ny uppgift
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Project Selector */}
|
|
{projects.length > 0 && (
|
|
<div className="flex gap-2 overflow-x-auto pb-2">
|
|
{projects.map((project) => (
|
|
<button
|
|
key={project.id}
|
|
onClick={() => {
|
|
setSelectedProject(project)
|
|
fetchTasks(project.id)
|
|
}}
|
|
className={`px-4 py-2 rounded-xl text-sm font-medium whitespace-nowrap transition-colors ${
|
|
selectedProject?.id === project.id
|
|
? 'bg-primary text-white'
|
|
: 'bg-surface text-text-secondary hover:bg-bg'
|
|
}`}
|
|
>
|
|
{project.name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Kanban Board */}
|
|
{selectedProject && (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
{statusColumns.map((column) => {
|
|
const columnTasks = getTasksByStatus(column.id)
|
|
const Icon = column.icon
|
|
return (
|
|
<div key={column.id} className="flex flex-col">
|
|
{/* Column Header */}
|
|
<div className={`flex items-center justify-between p-3 rounded-t-xl ${column.color}`}>
|
|
<div className="flex items-center gap-2">
|
|
<Icon size={16} className="text-text-secondary" />
|
|
<span className="font-medium text-sm">{column.label}</span>
|
|
</div>
|
|
<Badge variant="default" className="text-xs">
|
|
{columnTasks.length}
|
|
</Badge>
|
|
</div>
|
|
|
|
{/* Tasks */}
|
|
<div className="flex-1 bg-surface border border-t-0 rounded-b-xl p-2 space-y-2 min-h-[200px]">
|
|
{columnTasks.map((task) => (
|
|
<div
|
|
key={task.id}
|
|
className="p-3 bg-bg rounded-lg hover:shadow-md transition-shadow cursor-pointer group"
|
|
onClick={() => {
|
|
// Show task details modal
|
|
}}
|
|
>
|
|
<div className="flex items-start justify-between mb-2">
|
|
<p className="text-sm font-medium text-text-primary flex-1">
|
|
{task.title}
|
|
</p>
|
|
<button className="opacity-0 group-hover:opacity-100 p-1 hover:bg-surface rounded transition-opacity">
|
|
<MoreHorizontal size={14} className="text-text-secondary" />
|
|
</button>
|
|
</div>
|
|
|
|
{task.description && (
|
|
<p className="text-xs text-text-secondary mb-2 line-clamp-2">
|
|
{task.description}
|
|
</p>
|
|
)}
|
|
|
|
<div className="flex items-center justify-between">
|
|
<Badge
|
|
variant="default"
|
|
className={`text-xs ${priorityColors[task.priority]}`}
|
|
>
|
|
{priorityLabels[task.priority]}
|
|
</Badge>
|
|
|
|
{task.assignee && (
|
|
<div className="flex items-center gap-1 text-text-tertiary">
|
|
<User size={12} />
|
|
<span className="text-xs">{task.assignee}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{task.due_date && (
|
|
<div className="flex items-center gap-1 mt-2 text-text-tertiary">
|
|
<Calendar size={12} />
|
|
<span className="text-xs">
|
|
{new Date(task.due_date).toLocaleDateString('sv-SE')}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Quick status change */}
|
|
<div className="flex gap-1 mt-2 pt-2 border-t border-border">
|
|
{statusColumns
|
|
.filter((s) => s.id !== task.status)
|
|
.map((s) => (
|
|
<button
|
|
key={s.id}
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
updateTaskStatus(task.id, s.id)
|
|
}}
|
|
className="text-xs px-2 py-1 rounded bg-surface hover:bg-primary/10 text-text-secondary hover:text-primary transition-colors"
|
|
>
|
|
{s.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{columnTasks.length === 0 && (
|
|
<div className="text-center py-8 text-text-tertiary text-sm">
|
|
Inga uppgifter
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Create Task Modal */}
|
|
{showCreateTask && selectedProject && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
|
<div className="w-full max-w-lg bg-surface rounded-2xl shadow-xl">
|
|
<div className="flex items-center justify-between p-4 border-b border-border">
|
|
<h2 className="text-lg font-semibold">Ny uppgift</h2>
|
|
<button
|
|
onClick={() => setShowCreateTask(false)}
|
|
className="p-2 hover:bg-bg rounded-lg"
|
|
>
|
|
<X size={18} />
|
|
</button>
|
|
</div>
|
|
<div className="p-4 space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-text-secondary mb-1">
|
|
Titel
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={newTask.title}
|
|
onChange={(e) =>
|
|
setNewTask({ ...newTask, title: e.target.value })
|
|
}
|
|
className="w-full h-10 px-3 rounded-lg border bg-bg text-sm"
|
|
placeholder="Vad ska göras?"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-text-secondary mb-1">
|
|
Beskrivning
|
|
</label>
|
|
<textarea
|
|
value={newTask.description}
|
|
onChange={(e) =>
|
|
setNewTask({ ...newTask, description: e.target.value })
|
|
}
|
|
className="w-full h-24 px-3 py-2 rounded-lg border bg-bg text-sm resize-none"
|
|
placeholder="Beskriv uppgiften..."
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="block text-sm font-medium text-text-secondary mb-1">
|
|
Prioritet
|
|
</label>
|
|
<select
|
|
value={newTask.priority}
|
|
onChange={(e) =>
|
|
setNewTask({
|
|
...newTask,
|
|
priority: e.target.value as Task['priority'],
|
|
})
|
|
}
|
|
className="w-full h-10 px-3 rounded-lg border bg-bg text-sm"
|
|
>
|
|
<option value="low">Låg</option>
|
|
<option value="medium">Medium</option>
|
|
<option value="high">Hög</option>
|
|
<option value="urgent">Akut</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-text-secondary mb-1">
|
|
Tilldelad
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={newTask.assignee}
|
|
onChange={(e) =>
|
|
setNewTask({ ...newTask, assignee: e.target.value })
|
|
}
|
|
className="w-full h-10 px-3 rounded-lg border bg-bg text-sm"
|
|
placeholder="Namn"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-text-secondary mb-1">
|
|
Förfallodatum
|
|
</label>
|
|
<input
|
|
type="date"
|
|
value={newTask.due_date}
|
|
onChange={(e) =>
|
|
setNewTask({ ...newTask, due_date: e.target.value })
|
|
}
|
|
className="w-full h-10 px-3 rounded-lg border bg-bg text-sm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-3 p-4 border-t border-border">
|
|
<Button className="flex-1" onClick={createTask} disabled={!newTask.title}>
|
|
Skapa uppgift
|
|
</Button>
|
|
<Button variant="secondary" onClick={() => setShowCreateTask(false)}>
|
|
Avbryt
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Create Project Modal */}
|
|
{showCreateProject && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
|
<div className="w-full max-w-lg bg-surface rounded-2xl shadow-xl">
|
|
<div className="flex items-center justify-between p-4 border-b border-border">
|
|
<h2 className="text-lg font-semibold">Nytt projekt</h2>
|
|
<button
|
|
onClick={() => setShowCreateProject(false)}
|
|
className="p-2 hover:bg-bg rounded-lg"
|
|
>
|
|
<X size={18} />
|
|
</button>
|
|
</div>
|
|
<div className="p-4 space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-text-secondary mb-1">
|
|
Namn
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={newProject.name}
|
|
onChange={(e) =>
|
|
setNewProject({ ...newProject, name: e.target.value })
|
|
}
|
|
className="w-full h-10 px-3 rounded-lg border bg-bg text-sm"
|
|
placeholder="Projektnamn"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-text-secondary mb-1">
|
|
Beskrivning
|
|
</label>
|
|
<textarea
|
|
value={newProject.description}
|
|
onChange={(e) =>
|
|
setNewProject({ ...newProject, description: e.target.value })
|
|
}
|
|
className="w-full h-24 px-3 py-2 rounded-lg border bg-bg text-sm resize-none"
|
|
placeholder="Beskriv projektet..."
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-3 p-4 border-t border-border">
|
|
<Button
|
|
className="flex-1"
|
|
onClick={createProject}
|
|
disabled={!newProject.name}
|
|
>
|
|
Skapa projekt
|
|
</Button>
|
|
<Button variant="secondary" onClick={() => setShowCreateProject(false)}>
|
|
Avbryt
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|