e70ae79d-5898-4c55-8ceb-d2c701ce08b4
This commit is contained in:
1 parent
d226dabba3
commit
5855f699f3
53 files changed
+1911
-44
No files matched your search
@@ -21,6 +21,10 @@ import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { PageHeader } from '../primitives'
|
||||
import { formatEuro, formatDate, daysUntil, formatMinutes } from '@/lib/format'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -72,7 +76,7 @@ const TASK_ICON = {
|
||||
}
|
||||
|
||||
export function ProjectsView() {
|
||||
const { data, loading } = useFetch<ProjectsData>('/api/aos/projects')
|
||||
const { data, loading, refetch } = useFetch<ProjectsData>('/api/aos/projects')
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
|
||||
if (loading || !data) {
|
||||
@@ -179,7 +183,7 @@ export function ProjectsView() {
|
||||
|
||||
{/* Detail */}
|
||||
<div className="lg:col-span-3">
|
||||
{selected ? <ProjectDetail project={selected} /> : (
|
||||
{selected ? <ProjectDetail project={selected} onUpdated={refetch} /> : (
|
||||
<Card className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">Wähle ein Projekt.</p>
|
||||
</Card>
|
||||
@@ -190,13 +194,62 @@ export function ProjectsView() {
|
||||
)
|
||||
}
|
||||
|
||||
function ProjectDetail({ project }: { project: Project }) {
|
||||
function ProjectDetail({ project, onUpdated }: { project: Project; onUpdated?: () => void }) {
|
||||
const [running, setRunning] = useState(false)
|
||||
const [newTask, setNewTask] = useState('')
|
||||
const [taskPriority, setTaskPriority] = useState('medium')
|
||||
const [timeDesc, setTimeDesc] = useState('')
|
||||
const [timeMin, setTimeMin] = useState('')
|
||||
const [savingTask, setSavingTask] = useState(false)
|
||||
const [savingTime, setSavingTime] = useState(false)
|
||||
const todo = project.tasks.filter((t) => t.status === 'todo')
|
||||
const inProgress = project.tasks.filter((t) => t.status === 'in-progress')
|
||||
const review = project.tasks.filter((t) => t.status === 'review')
|
||||
const done = project.tasks.filter((t) => t.status === 'done')
|
||||
|
||||
const addTask = async () => {
|
||||
if (!newTask.trim()) return
|
||||
setSavingTask(true)
|
||||
const t = toast.loading('Aufgabe wird hinzugefügt…')
|
||||
try {
|
||||
const r = await fetch(`/api/aos/projects/${project.id}/tasks`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ title: newTask.trim(), priority: taskPriority }),
|
||||
})
|
||||
if (!r.ok) throw new Error('task failed')
|
||||
toast.success('Aufgabe hinzugefügt', { id: t })
|
||||
setNewTask('')
|
||||
onUpdated?.()
|
||||
} catch {
|
||||
toast.error('Speichern fehlgeschlagen', { id: t })
|
||||
} finally {
|
||||
setSavingTask(false)
|
||||
}
|
||||
}
|
||||
|
||||
const logTime = async () => {
|
||||
const minutes = parseInt(timeMin, 10)
|
||||
if (!timeDesc.trim() || !minutes || minutes < 1) return
|
||||
setSavingTime(true)
|
||||
const t = toast.loading('Zeiteintrag wird gespeichert…')
|
||||
try {
|
||||
const r = await fetch(`/api/aos/projects/${project.id}/time`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ description: timeDesc.trim(), minutes, billable: true, rateCentsPerHour: 9500 }),
|
||||
})
|
||||
if (!r.ok) throw new Error('time failed')
|
||||
toast.success(`${minutes} min gebucht`, { id: t })
|
||||
setTimeDesc(''); setTimeMin('')
|
||||
onUpdated?.()
|
||||
} catch {
|
||||
toast.error('Buchen fehlgeschlagen', { id: t })
|
||||
} finally {
|
||||
setSavingTime(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="border-b border-border pb-3">
|
||||
@@ -220,6 +273,28 @@ function ProjectDetail({ project }: { project: Project }) {
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="tasks" className="mt-4 space-y-4">
|
||||
{/* Add task inline */}
|
||||
<div className="flex items-center gap-2 rounded-md border border-border bg-muted/20 p-2">
|
||||
<Input
|
||||
value={newTask}
|
||||
onChange={(e) => setNewTask(e.target.value)}
|
||||
placeholder="Neue Aufgabe…"
|
||||
className="h-8 flex-1 text-xs"
|
||||
onKeyDown={(e) => e.key === 'Enter' && addTask()}
|
||||
/>
|
||||
<Select value={taskPriority} onValueChange={setTaskPriority}>
|
||||
<SelectTrigger className="h-8 w-28 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="low">Niedrig</SelectItem>
|
||||
<SelectItem value="medium">Mittel</SelectItem>
|
||||
<SelectItem value="high">Hoch</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button size="sm" className="h-8 gap-1.5" onClick={addTask} disabled={savingTask || !newTask.trim()}>
|
||||
{savingTask ? <Loader2 className="size-3.5 animate-spin" /> : <Plus className="size-3.5" />}
|
||||
Hinzufügen
|
||||
</Button>
|
||||
</div>
|
||||
<TaskColumn label="Offen" tasks={todo} />
|
||||
<TaskColumn label="In Arbeit" tasks={inProgress} />
|
||||
<TaskColumn label="Review" tasks={review} />
|
||||
@@ -260,6 +335,33 @@ function ProjectDetail({ project }: { project: Project }) {
|
||||
<p className="py-6 text-center text-xs text-muted-foreground">Keine Zeiteinträge.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Log time inline */}
|
||||
<div className="flex items-end gap-2 rounded-md border border-border bg-muted/20 p-2">
|
||||
<div className="flex-1 space-y-1">
|
||||
<label className="text-[10px] uppercase tracking-wide text-muted-foreground">Beschreibung</label>
|
||||
<Input
|
||||
value={timeDesc}
|
||||
onChange={(e) => setTimeDesc(e.target.value)}
|
||||
placeholder="z.B. SSO-Integration"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-24 space-y-1">
|
||||
<label className="text-[10px] uppercase tracking-wide text-muted-foreground">Minuten</label>
|
||||
<Input
|
||||
value={timeMin}
|
||||
onChange={(e) => setTimeMin(e.target.value)}
|
||||
placeholder="90"
|
||||
inputMode="numeric"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<Button size="sm" className="h-8 gap-1.5" onClick={logTime} disabled={savingTime || !timeDesc.trim() || !timeMin}>
|
||||
{savingTime ? <Loader2 className="size-3.5 animate-spin" /> : <Plus className="size-3.5" />}
|
||||
Buchen
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="budget" className="mt-4 space-y-3">
|
||||
|
||||
Reference in new issue
Block a user