Files
boc/web-v2/src/pages/ProjectsPage.tsx
T
Bernt 78b57273e2 security: Add proper authentication, RBAC, and tenant isolation
- 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
2026-08-10 12:52:48 +00:00

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>
)
}