Update
This commit is contained in:
1 parent
b7f7d7e793
commit
c01d747352
14 files changed
+987
-294
No files matched your search
@@ -5,6 +5,7 @@ import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -18,6 +19,19 @@ import { currentMonth, formatMinutes } from "../format";
|
||||
import { projectHasRecurringBilling } from "../recurring-billings";
|
||||
import { ticketPattern } from "../timers";
|
||||
import type { ProjectSummary, ProjectTeamspaceEntry, TicketMeta } from "../types";
|
||||
import {
|
||||
matchesZammadPriorityFilter,
|
||||
matchesZammadStateFilter,
|
||||
ZammadPriorityBadge,
|
||||
zammadPriorityFilterOptions,
|
||||
zammadPriorityLabel,
|
||||
zammadPrioritySortRank,
|
||||
ZammadStateBadge,
|
||||
zammadStateFilterOptions,
|
||||
zammadStateLabel,
|
||||
zammadStateSortRank
|
||||
} from "../zammad-meta";
|
||||
import type { ZammadPriorityFilter, ZammadStateFilter } from "../zammad-meta";
|
||||
|
||||
type ProjectsPageProps = {
|
||||
onNavigate: (to: string) => void;
|
||||
@@ -39,22 +53,23 @@ type ProjectTeamspaceEntryForm = {
|
||||
clientId: string;
|
||||
teamspaceProject: string;
|
||||
hourBudget: string;
|
||||
budgetSyncDisabled: boolean;
|
||||
manualConsumption: string;
|
||||
isDefault: boolean;
|
||||
};
|
||||
|
||||
type ProjectStatusFilter = "open" | "all" | "closed" | "hidden";
|
||||
type ProjectBudgetFilter = "all" | "with-budget" | "without-budget" | "remaining" | "over-budget";
|
||||
type ProjectTodoFilter = "all" | "open" | "overdue" | "none-open";
|
||||
type ProjectSortBy = "updated" | "ticket" | "title" | "organization" | "status" | "consumption" | "consumptionPercent" | "remaining" | "budget" | "todos";
|
||||
type ProjectSortBy = "updated" | "ticket" | "title" | "organization" | "status" | "zammadStatus" | "zammadPriority" | "consumption" | "consumptionPercent" | "remaining" | "budget";
|
||||
type ProjectSortDirection = "asc" | "desc";
|
||||
|
||||
type ProjectViewSettings = {
|
||||
query: string;
|
||||
status: ProjectStatusFilter;
|
||||
zammadStatus: ZammadStateFilter;
|
||||
zammadPriority: ZammadPriorityFilter;
|
||||
organization: string;
|
||||
budget: ProjectBudgetFilter;
|
||||
todos: ProjectTodoFilter;
|
||||
sortBy: ProjectSortBy;
|
||||
sortDirection: ProjectSortDirection;
|
||||
};
|
||||
@@ -64,9 +79,10 @@ const projectViewSettingsKey = "tickettracker.projects.viewSettings";
|
||||
const defaultProjectViewSettings: ProjectViewSettings = {
|
||||
query: "",
|
||||
status: "open",
|
||||
zammadStatus: "all",
|
||||
zammadPriority: "all",
|
||||
organization: "all",
|
||||
budget: "all",
|
||||
todos: "all",
|
||||
sortBy: "updated",
|
||||
sortDirection: "desc"
|
||||
};
|
||||
@@ -86,14 +102,7 @@ const projectBudgetOptions: Array<{ value: ProjectBudgetFilter; label: string }>
|
||||
{ value: "over-budget", label: "Überzogen" }
|
||||
];
|
||||
|
||||
const projectTodoOptions: Array<{ value: ProjectTodoFilter; label: string }> = [
|
||||
{ value: "all", label: "Alle ToDos" },
|
||||
{ value: "open", label: "Mit offenen ToDos" },
|
||||
{ value: "overdue", label: "Mit überfälligen ToDos" },
|
||||
{ value: "none-open", label: "Keine offenen ToDos" }
|
||||
];
|
||||
|
||||
const projectSortValues: ProjectSortBy[] = ["updated", "ticket", "title", "organization", "status", "consumption", "consumptionPercent", "remaining", "budget", "todos"];
|
||||
const projectSortValues: ProjectSortBy[] = ["updated", "ticket", "title", "organization", "status", "zammadStatus", "zammadPriority", "consumption", "consumptionPercent", "remaining", "budget"];
|
||||
|
||||
const projectSortOptions: Array<{ value: ProjectSortBy; label: string }> = [
|
||||
{ value: "updated", label: "Aktualisierung" },
|
||||
@@ -101,11 +110,12 @@ const projectSortOptions: Array<{ value: ProjectSortBy; label: string }> = [
|
||||
{ value: "title", label: "Titel" },
|
||||
{ value: "organization", label: "Organisation" },
|
||||
{ value: "status", label: "Status" },
|
||||
{ value: "zammadStatus", label: "Zammad-Status" },
|
||||
{ value: "zammadPriority", label: "Zammad-Priorität" },
|
||||
{ value: "consumption", label: "Verbrauch" },
|
||||
{ value: "consumptionPercent", label: "Verbrauch %" },
|
||||
{ value: "remaining", label: "Reststunden" },
|
||||
{ value: "budget", label: "Kontingent" },
|
||||
{ value: "todos", label: "ToDos" }
|
||||
{ value: "budget", label: "Kontingent" }
|
||||
];
|
||||
|
||||
function projectEntryId() {
|
||||
@@ -121,6 +131,7 @@ function emptyProjectTeamspaceEntry(isDefault = false): ProjectTeamspaceEntryFor
|
||||
clientId: projectEntryId(),
|
||||
teamspaceProject: "",
|
||||
hourBudget: "",
|
||||
budgetSyncDisabled: false,
|
||||
manualConsumption: "",
|
||||
isDefault
|
||||
};
|
||||
@@ -143,9 +154,10 @@ function readStoredProjectViewSettings(): ProjectViewSettings {
|
||||
return {
|
||||
query: typeof parsed.query === "string" ? parsed.query : defaultProjectViewSettings.query,
|
||||
status: projectStatusOptions.find((option) => option.value === parsed.status)?.value ?? defaultProjectViewSettings.status,
|
||||
zammadStatus: zammadStateFilterOptions.find((option) => option.value === parsed.zammadStatus)?.value ?? defaultProjectViewSettings.zammadStatus,
|
||||
zammadPriority: zammadPriorityFilterOptions.find((option) => option.value === parsed.zammadPriority)?.value ?? defaultProjectViewSettings.zammadPriority,
|
||||
organization: typeof parsed.organization === "string" ? parsed.organization : defaultProjectViewSettings.organization,
|
||||
budget: projectBudgetOptions.find((option) => option.value === parsed.budget)?.value ?? defaultProjectViewSettings.budget,
|
||||
todos: projectTodoOptions.find((option) => option.value === parsed.todos)?.value ?? defaultProjectViewSettings.todos,
|
||||
sortBy: projectSortValues.find((value) => value === parsed.sortBy) ?? defaultProjectViewSettings.sortBy,
|
||||
sortDirection: parsed.sortDirection === "asc" ? "asc" : defaultProjectViewSettings.sortDirection
|
||||
};
|
||||
@@ -295,6 +307,8 @@ function projectMatchesQuery(project: ProjectSummary, query: string) {
|
||||
project.title ?? "",
|
||||
projectOrganizationLabel(project),
|
||||
project.teamspace_project ?? "",
|
||||
zammadStateLabel(project.zammad_state_id),
|
||||
zammadPriorityLabel(project.zammad_priority_id),
|
||||
...projectTeamspaceEntries(project).map((entry) => entry.teamspace_project ?? ""),
|
||||
project.description,
|
||||
project.goals
|
||||
@@ -315,6 +329,10 @@ function compareProjects(left: ProjectSummary, right: ProjectSummary, sortBy: Pr
|
||||
comparison = compareText(projectOrganizationLabel(left), projectOrganizationLabel(right));
|
||||
} else if (sortBy === "status") {
|
||||
comparison = Number(Boolean(left.closed_at)) - Number(Boolean(right.closed_at));
|
||||
} else if (sortBy === "zammadStatus") {
|
||||
comparison = zammadStateSortRank(left.zammad_state_id) - zammadStateSortRank(right.zammad_state_id);
|
||||
} else if (sortBy === "zammadPriority") {
|
||||
comparison = zammadPrioritySortRank(left.zammad_priority_id) - zammadPrioritySortRank(right.zammad_priority_id);
|
||||
} else if (sortBy === "consumption") {
|
||||
comparison = projectConsumption(left) - projectConsumption(right);
|
||||
} else if (sortBy === "consumptionPercent") {
|
||||
@@ -330,8 +348,6 @@ function compareProjects(left: ProjectSummary, right: ProjectSummary, sortBy: Pr
|
||||
comparison = (projectRemaining(left) ?? Number.POSITIVE_INFINITY) - (projectRemaining(right) ?? Number.POSITIVE_INFINITY);
|
||||
} else if (sortBy === "budget") {
|
||||
comparison = (left.hour_budget_minutes ?? Number.POSITIVE_INFINITY) - (right.hour_budget_minutes ?? Number.POSITIVE_INFINITY);
|
||||
} else if (sortBy === "todos") {
|
||||
comparison = Number(left.open_todos ?? 0) - Number(right.open_todos ?? 0);
|
||||
} else {
|
||||
comparison = new Date(left.updated_at).getTime() - new Date(right.updated_at).getTime();
|
||||
}
|
||||
@@ -344,6 +360,7 @@ function formFromProject(project: ProjectSummary): ProjectForm {
|
||||
clientId: entry.id || projectEntryId(),
|
||||
teamspaceProject: entry.teamspace_project ?? "",
|
||||
hourBudget: formatHoursInput(entry.hour_budget_minutes),
|
||||
budgetSyncDisabled: entry.budget_sync_disabled ?? false,
|
||||
manualConsumption: formatHoursInput(entry.manual_consumption_minutes),
|
||||
isDefault: entry.is_default || index === 0
|
||||
}));
|
||||
@@ -398,6 +415,7 @@ function parseProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[]) {
|
||||
teamspaceProject: entry.teamspaceProject.trim() || null,
|
||||
isDefault: entry.isDefault,
|
||||
hourBudgetMinutes,
|
||||
budgetSyncDisabled: entry.budgetSyncDisabled,
|
||||
manualConsumptionMinutes: manualConsumptionMinutes ?? 0
|
||||
};
|
||||
});
|
||||
@@ -411,6 +429,7 @@ function parseProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[]) {
|
||||
teamspaceProject: string | null;
|
||||
isDefault: boolean;
|
||||
hourBudgetMinutes: number | null;
|
||||
budgetSyncDisabled: boolean;
|
||||
manualConsumptionMinutes: number;
|
||||
}>;
|
||||
}
|
||||
@@ -451,7 +470,7 @@ function ProjectTeamspaceEntriesFields({
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{normalizedEntries.map((entry, index) => (
|
||||
<div key={entry.clientId} className="grid gap-2 rounded-lg border bg-muted/10 p-3 lg:grid-cols-[minmax(180px,1fr)_130px_150px_120px_36px] lg:items-end">
|
||||
<div key={entry.clientId} className="grid gap-2 rounded-lg border bg-muted/10 p-3 lg:grid-cols-[minmax(180px,1fr)_130px_150px_130px_120px_36px] lg:items-end">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground" htmlFor={`project-teamspace-${entry.clientId}`}>
|
||||
Teamspace-Projekt {entry.isDefault ? "(Standard)" : ""}
|
||||
@@ -470,9 +489,13 @@ function ProjectTeamspaceEntriesFields({
|
||||
id={`project-budget-${entry.clientId}`}
|
||||
inputMode="decimal"
|
||||
value={entry.hourBudget}
|
||||
onChange={(event) => updateEntry(entry.clientId, { hourBudget: event.currentTarget.value })}
|
||||
onChange={(event) => updateEntry(entry.clientId, { hourBudget: event.currentTarget.value, budgetSyncDisabled: true })}
|
||||
/>
|
||||
</div>
|
||||
<label className="flex min-h-9 items-center gap-2 rounded-md border bg-background px-3 text-xs">
|
||||
<Checkbox checked={entry.budgetSyncDisabled} onCheckedChange={(checked) => updateEntry(entry.clientId, { budgetSyncDisabled: checked === true })} />
|
||||
<span>Budget schützen</span>
|
||||
</label>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground" htmlFor={`project-manual-${entry.clientId}`}>
|
||||
Manueller Verbrauch h
|
||||
@@ -716,6 +739,14 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!matchesZammadStateFilter(project.zammad_state_id, viewSettings.zammadStatus)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!matchesZammadPriorityFilter(project.zammad_priority_id, viewSettings.zammadPriority)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (viewSettings.organization !== "all" && projectOrganizationKey(project) !== viewSettings.organization) {
|
||||
return false;
|
||||
}
|
||||
@@ -742,23 +773,10 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (viewSettings.todos === "open" && Number(project.open_todos ?? 0) === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (viewSettings.todos === "overdue" && Number(project.overdue_todos ?? 0) === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (viewSettings.todos === "none-open" && Number(project.open_todos ?? 0) > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.sort((left, right) => compareProjects(left, right, viewSettings.sortBy, viewSettings.sortDirection));
|
||||
}, [projects, viewSettings]);
|
||||
const openTodos = useMemo(() => filteredProjects.reduce((sum, project) => sum + Number(project.open_todos ?? 0), 0), [filteredProjects]);
|
||||
const projectConsumptionMinutes = useMemo(() => filteredProjects.reduce((sum, project) => sum + projectConsumption(project), 0), [filteredProjects]);
|
||||
const remainingMinutes = useMemo(
|
||||
() => filteredProjects.reduce((sum, project) => sum + Math.max(projectRemaining(project) ?? 0, 0), 0),
|
||||
@@ -767,9 +785,10 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
||||
const activeFilterCount = [
|
||||
viewSettings.query.trim() !== "",
|
||||
viewSettings.status !== defaultProjectViewSettings.status,
|
||||
viewSettings.zammadStatus !== defaultProjectViewSettings.zammadStatus,
|
||||
viewSettings.zammadPriority !== defaultProjectViewSettings.zammadPriority,
|
||||
viewSettings.organization !== defaultProjectViewSettings.organization,
|
||||
viewSettings.budget !== defaultProjectViewSettings.budget,
|
||||
viewSettings.todos !== defaultProjectViewSettings.todos,
|
||||
viewSettings.sortBy !== defaultProjectViewSettings.sortBy,
|
||||
viewSettings.sortDirection !== defaultProjectViewSettings.sortDirection
|
||||
].filter(Boolean).length;
|
||||
@@ -808,17 +827,28 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
||||
setLookupStatus("found");
|
||||
setUseZammadForCreate(true);
|
||||
setForm((current) => {
|
||||
const entries = normalizeProjectTeamspaceEntryForms(current.teamspaceEntries);
|
||||
const defaultIndex = Math.max(0, entries.findIndex((entry) => entry.isDefault));
|
||||
const nextEntries = entries.map((entry, index) =>
|
||||
index === defaultIndex
|
||||
? {
|
||||
...entry,
|
||||
teamspaceProject: entry.teamspaceProject || result.ticket?.project_name || "",
|
||||
hourBudget: entry.hourBudget || formatHoursInput(result.ticket?.project_hour_budget_minutes ?? null)
|
||||
}
|
||||
: entry
|
||||
);
|
||||
const zammadEntries = result.ticket?.project_teamspace_entries ?? [];
|
||||
const nextEntries = zammadEntries.length > 0
|
||||
? zammadEntries.map((entry, index) => ({
|
||||
clientId: entry.id || projectEntryId(),
|
||||
teamspaceProject: entry.teamspace_project ?? "",
|
||||
hourBudget: formatHoursInput(entry.hour_budget_minutes),
|
||||
budgetSyncDisabled: entry.budget_sync_disabled ?? false,
|
||||
manualConsumption: "",
|
||||
isDefault: entry.is_default || index === 0
|
||||
}))
|
||||
: normalizeProjectTeamspaceEntryForms(current.teamspaceEntries).map((entry, index, entries) => {
|
||||
const defaultIndex = Math.max(0, entries.findIndex((candidate) => candidate.isDefault));
|
||||
|
||||
return index === defaultIndex
|
||||
? {
|
||||
...entry,
|
||||
teamspaceProject: entry.teamspaceProject || result.ticket?.project_name || "",
|
||||
hourBudget: entry.hourBudget || formatHoursInput(result.ticket?.project_hour_budget_minutes ?? null),
|
||||
budgetSyncDisabled: entry.hourBudget ? entry.budgetSyncDisabled : false
|
||||
}
|
||||
: entry;
|
||||
});
|
||||
|
||||
return {
|
||||
...current,
|
||||
@@ -1050,10 +1080,6 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
||||
<span className="block text-muted-foreground">Rest offen</span>
|
||||
<span className="font-semibold">{formatMinutes(remainingMinutes)}</span>
|
||||
</div>
|
||||
<div className="w-28 shrink-0 rounded-md border bg-muted/20 px-3 py-2">
|
||||
<span className="block text-muted-foreground">Offene ToDos</span>
|
||||
<span className="font-semibold">{openTodos}</span>
|
||||
</div>
|
||||
</div>
|
||||
<HelpLink anchor="projekte" label="Hilfe zu Projekten" className="shrink-0" />
|
||||
</div>
|
||||
@@ -1076,7 +1102,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 px-4 pb-4 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-[minmax(220px,1.4fr)_150px_minmax(180px,1fr)_170px_170px_170px_150px]">
|
||||
<CardContent className="grid gap-3 px-4 pb-4 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground" htmlFor="project-search">
|
||||
Suche
|
||||
@@ -1105,6 +1131,32 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">Zammad-Status</label>
|
||||
<Select value={viewSettings.zammadStatus} onValueChange={(value) => setViewSettings((current) => ({ ...current, zammadStatus: value as ZammadStateFilter }))}>
|
||||
<SelectTrigger size="sm" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{zammadStateFilterOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">Zammad-Priorität</label>
|
||||
<Select value={viewSettings.zammadPriority} onValueChange={(value) => setViewSettings((current) => ({ ...current, zammadPriority: value as ZammadPriorityFilter }))}>
|
||||
<SelectTrigger size="sm" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{zammadPriorityFilterOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">Organisation</label>
|
||||
<Select value={viewSettings.organization} onValueChange={(value) => setViewSettings((current) => ({ ...current, organization: value }))}>
|
||||
@@ -1132,19 +1184,6 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">ToDos</label>
|
||||
<Select value={viewSettings.todos} onValueChange={(value) => setViewSettings((current) => ({ ...current, todos: value as ProjectTodoFilter }))}>
|
||||
<SelectTrigger size="sm" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{projectTodoOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">Sortierung</label>
|
||||
<Select value={viewSettings.sortBy} onValueChange={(value) => setViewSettings((current) => ({ ...current, sortBy: value as ProjectSortBy }))}>
|
||||
@@ -1236,13 +1275,14 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
<div className="hidden grid-cols-[minmax(220px,1.35fr)_minmax(180px,1fr)_100px_170px_95px_110px_44px] gap-3 border-b bg-muted/25 px-4 py-2 text-xs font-medium text-muted-foreground xl:grid">
|
||||
<div className="hidden grid-cols-[minmax(220px,1.35fr)_minmax(170px,1fr)_105px_115px_105px_165px_90px_44px] gap-3 border-b bg-muted/25 px-4 py-2 text-xs font-medium text-muted-foreground xl:grid">
|
||||
{sortHeader("Nummer/Titel", "title")}
|
||||
{sortHeader("Organisation", "organization")}
|
||||
{sortHeader("Status", "status")}
|
||||
{sortHeader("Zammad", "zammadStatus")}
|
||||
{sortHeader("Prio", "zammadPriority")}
|
||||
{sortHeader("Verbrauch/Gesamt", "consumption")}
|
||||
{sortHeader("Verbrauch %", "consumptionPercent")}
|
||||
{sortHeader("ToDos", "todos")}
|
||||
<span className="text-right">Aktion</span>
|
||||
</div>
|
||||
{filteredProjects.map((project) => {
|
||||
@@ -1256,7 +1296,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
||||
|
||||
return (
|
||||
<div key={project.id} className="border-b last:border-b-0">
|
||||
<div className="grid gap-3 px-4 py-3 xl:grid-cols-[minmax(220px,1.35fr)_minmax(180px,1fr)_100px_170px_95px_110px_44px] xl:items-center">
|
||||
<div className="grid gap-3 px-4 py-3 xl:grid-cols-[minmax(220px,1.35fr)_minmax(170px,1fr)_105px_115px_105px_165px_90px_44px] xl:items-center">
|
||||
<div className="min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
@@ -1272,12 +1312,22 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
||||
<p className="truncate">{projectOrganizationLabel(project)}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{project.closed_at ? <Badge variant="secondary">geschlossen</Badge> : <Badge variant="success">offen</Badge>}
|
||||
<p className="w-full truncate text-sm font-medium xl:hidden">Status</p>
|
||||
{project.closed_at ? <Badge variant="secondary">Geschlossen</Badge> : <Badge variant="success">Offen</Badge>}
|
||||
{!canEditProject ? <Badge variant="outline">geteilt</Badge> : null}
|
||||
{project.close_review_required ? <Badge variant="warning">TS/TT prüfen</Badge> : null}
|
||||
{(project.unassigned_teamspace_session_count ?? 0) > 0 ? <Badge variant="warning">{project.unassigned_teamspace_session_count} ohne TS</Badge> : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<p className="w-full truncate text-sm font-medium xl:hidden">Zammad</p>
|
||||
<ZammadStateBadge stateId={project.zammad_state_id} />
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<p className="w-full truncate text-sm font-medium xl:hidden">Prio</p>
|
||||
<ZammadPriorityBadge priorityId={project.zammad_priority_id} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium xl:hidden">Verbrauch/Gesamt</p>
|
||||
<p className={cn("text-sm font-semibold", remaining !== null && remaining < 0 ? "text-destructive" : undefined)}>
|
||||
{formatDecimalHours(consumptionMinutes)} / {project.hour_budget_minutes ? formatDecimalHours(project.hour_budget_minutes) : "offen"}
|
||||
</p>
|
||||
@@ -1289,10 +1339,6 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
||||
{usedPercent !== null ? `${usedPercent}%` : "-"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Badge variant={(project.open_todos ?? 0) > 0 ? "warning" : "success"}>{project.open_todos ?? 0} offen</Badge>
|
||||
{(project.overdue_todos ?? 0) > 0 ? <Badge variant="destructive">{project.overdue_todos} üf</Badge> : null}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -1383,6 +1429,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
||||
<span className="truncate text-muted-foreground">
|
||||
{label}
|
||||
{entry.is_default ? " · Standard" : ""}
|
||||
{entry.budget_sync_disabled ? " · Budget geschützt" : ""}
|
||||
</span>
|
||||
<span className="shrink-0 font-medium">
|
||||
{formatMinutes(entryConsumption)} / {entry.hour_budget_minutes ? formatMinutes(entry.hour_budget_minutes) : "offen"}
|
||||
|
||||
Reference in new issue
Block a user