1655 lines
68 KiB
TypeScript
1655 lines
68 KiB
TypeScript
import { ArrowDown, ArrowUp, CheckCircle2, Eye, EyeOff, MoreHorizontal, Pencil, Play, Plus, RotateCcw, Save, Search, SlidersHorizontal, Star, Trash2 } from "lucide-react";
|
|
import { FormEvent, useEffect, useMemo, useState } from "react";
|
|
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";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { HelpLink } from "@/components/HelpLink";
|
|
import { MultiFilterDropdown, type MultiFilterOption } from "@/components/MultiFilterDropdown";
|
|
import { OrganizationSelect } from "@/components/OrganizationSelect";
|
|
import { cn } from "@/lib/utils";
|
|
import { addFavoriteTicket, createProject, getProjects, hideProject, lookupTicket, unhideProject, updateProject, updateProjectStatus } from "../api";
|
|
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";
|
|
|
|
type ProjectsPageProps = {
|
|
onNavigate: (to: string) => void;
|
|
onStartTimer: (ticketNumber: string) => Promise<boolean>;
|
|
};
|
|
|
|
type ProjectForm = {
|
|
ticketNumber: string;
|
|
title: string;
|
|
organizationId: string;
|
|
organizationName: string | null;
|
|
description: string;
|
|
startsOn: string;
|
|
endsOn: string;
|
|
teamspaceEntries: ProjectTeamspaceEntryForm[];
|
|
};
|
|
|
|
type ProjectTeamspaceEntryForm = {
|
|
clientId: string;
|
|
teamspaceProject: string;
|
|
hourBudget: string;
|
|
budgetSyncDisabled: boolean;
|
|
manualConsumption: string;
|
|
isDefault: boolean;
|
|
};
|
|
|
|
type ProjectSortBy = "updated" | "ticket" | "title" | "organization" | "status" | "zammadStatus" | "zammadPriority" | "consumption" | "consumptionPercent" | "remaining" | "budget";
|
|
type ProjectSortDirection = "asc" | "desc";
|
|
type ProjectFilterOption = MultiFilterOption;
|
|
|
|
type ProjectViewSettings = {
|
|
query: string;
|
|
filterValues: string[];
|
|
organizationValues: string[];
|
|
standardTeamspaceOnly: boolean;
|
|
sortBy: ProjectSortBy;
|
|
sortDirection: ProjectSortDirection;
|
|
};
|
|
|
|
const projectViewSettingsKey = "tickettracker.projects.viewSettings";
|
|
|
|
const defaultProjectViewSettings: ProjectViewSettings = {
|
|
query: "",
|
|
filterValues: ["open"],
|
|
organizationValues: [],
|
|
standardTeamspaceOnly: false,
|
|
sortBy: "updated",
|
|
sortDirection: "desc"
|
|
};
|
|
|
|
const projectStaticFilterOptions: ProjectFilterOption[] = [
|
|
{ value: "open", label: "TT Offen" },
|
|
{ value: "closed", label: "TT Geschl." },
|
|
{ value: "hidden", label: "Ausgebl." },
|
|
{ value: "budgeted", label: "Mit Kontingent" },
|
|
{ value: "withoutBudget", label: "Ohne Kontingent" },
|
|
{ value: "remaining", label: "Mit Rest" },
|
|
{ value: "overBudget", label: "Überzogen" },
|
|
...zammadStateFilterOptions.filter((option) => option.value !== "all").map((option) => ({ value: `zammadState:${option.value}`, label: `ZA ${option.label}` })),
|
|
...zammadPriorityFilterOptions.filter((option) => option.value !== "all").map((option) => ({ value: `zammadPriority:${option.value}`, label: `ZA Prio ${option.label}` }))
|
|
];
|
|
|
|
const projectStaticFilterValues = new Set(projectStaticFilterOptions.map((option) => option.value));
|
|
|
|
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" },
|
|
{ value: "ticket", label: "Ticket" },
|
|
{ value: "title", label: "Titel" },
|
|
{ value: "organization", label: "Organisation" },
|
|
{ value: "status", label: "Status" },
|
|
{ value: "zammadStatus", label: "ZA Status" },
|
|
{ value: "zammadPriority", label: "ZA Prio" },
|
|
{ value: "consumption", label: "Verbrauch" },
|
|
{ value: "consumptionPercent", label: "Verbrauch %" },
|
|
{ value: "remaining", label: "Reststunden" },
|
|
{ value: "budget", label: "Kontingent" }
|
|
];
|
|
|
|
function projectEntryId() {
|
|
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
return crypto.randomUUID();
|
|
}
|
|
|
|
return `entry-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
}
|
|
|
|
function emptyProjectTeamspaceEntry(isDefault = false): ProjectTeamspaceEntryForm {
|
|
return {
|
|
clientId: projectEntryId(),
|
|
teamspaceProject: "",
|
|
hourBudget: "",
|
|
budgetSyncDisabled: false,
|
|
manualConsumption: "",
|
|
isDefault
|
|
};
|
|
}
|
|
|
|
function isKnownProjectFilterValue(value: unknown): value is string {
|
|
if (typeof value !== "string") {
|
|
return false;
|
|
}
|
|
|
|
return projectStaticFilterValues.has(value);
|
|
}
|
|
|
|
function normalizedProjectFilterValues(values: unknown, fallback = defaultProjectViewSettings.filterValues) {
|
|
if (!Array.isArray(values)) {
|
|
return fallback;
|
|
}
|
|
|
|
return Array.from(new Set(values.filter(isKnownProjectFilterValue)));
|
|
}
|
|
|
|
function legacyProjectBudgetFilter(value: unknown) {
|
|
if (value === "with-budget") {
|
|
return "budgeted";
|
|
}
|
|
|
|
if (value === "without-budget") {
|
|
return "withoutBudget";
|
|
}
|
|
|
|
if (value === "remaining") {
|
|
return "remaining";
|
|
}
|
|
|
|
if (value === "over-budget") {
|
|
return "overBudget";
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function legacyProjectFilters(parsed: Partial<ProjectViewSettings> & Record<string, unknown>) {
|
|
const filters: string[] = [];
|
|
const hasLegacyFilter = ["status", "zammadStatus", "zammadPriority", "organization", "budget"].some((key) => Object.prototype.hasOwnProperty.call(parsed, key));
|
|
|
|
if (parsed.status === "open" || parsed.status === "closed" || parsed.status === "hidden") {
|
|
filters.push(parsed.status);
|
|
}
|
|
|
|
if (typeof parsed.zammadStatus === "string" && parsed.zammadStatus !== "all" && zammadStateFilterOptions.some((option) => option.value === parsed.zammadStatus)) {
|
|
filters.push(`zammadState:${parsed.zammadStatus}`);
|
|
}
|
|
|
|
if (typeof parsed.zammadPriority === "string" && parsed.zammadPriority !== "all" && zammadPriorityFilterOptions.some((option) => option.value === parsed.zammadPriority)) {
|
|
filters.push(`zammadPriority:${parsed.zammadPriority}`);
|
|
}
|
|
|
|
const budgetFilter = legacyProjectBudgetFilter(parsed.budget);
|
|
|
|
if (budgetFilter) {
|
|
filters.push(budgetFilter);
|
|
}
|
|
|
|
if (hasLegacyFilter) {
|
|
return Array.from(new Set(filters));
|
|
}
|
|
|
|
return filters.length > 0 ? Array.from(new Set(filters)) : defaultProjectViewSettings.filterValues;
|
|
}
|
|
|
|
function normalizedProjectOrganizationValues(values: unknown) {
|
|
if (!Array.isArray(values)) {
|
|
return [];
|
|
}
|
|
|
|
return Array.from(
|
|
new Set(
|
|
values
|
|
.map((value) => {
|
|
if (typeof value !== "string") {
|
|
return "";
|
|
}
|
|
|
|
return value.startsWith("organization:") ? value.slice("organization:".length).trim() : value.trim();
|
|
})
|
|
.filter(Boolean)
|
|
)
|
|
);
|
|
}
|
|
|
|
function legacyProjectOrganizationValues(parsed: Partial<ProjectViewSettings> & Record<string, unknown>) {
|
|
if (Array.isArray(parsed.organizationValues)) {
|
|
return normalizedProjectOrganizationValues(parsed.organizationValues);
|
|
}
|
|
|
|
if (Array.isArray(parsed.filterValues)) {
|
|
return normalizedProjectOrganizationValues(parsed.filterValues.filter((value) => typeof value === "string" && value.startsWith("organization:")));
|
|
}
|
|
|
|
if (typeof parsed.organization === "string" && parsed.organization !== "all") {
|
|
return [parsed.organization];
|
|
}
|
|
|
|
return defaultProjectViewSettings.organizationValues;
|
|
}
|
|
|
|
function legacyStandardTeamspaceOnly(parsed: Partial<ProjectViewSettings> & Record<string, unknown>) {
|
|
if (typeof parsed.standardTeamspaceOnly === "boolean") {
|
|
return parsed.standardTeamspaceOnly;
|
|
}
|
|
|
|
return Array.isArray(parsed.filterValues) && parsed.filterValues.includes("standardBudgetOnly");
|
|
}
|
|
|
|
function readStoredProjectViewSettings(): ProjectViewSettings {
|
|
if (typeof window === "undefined") {
|
|
return defaultProjectViewSettings;
|
|
}
|
|
|
|
try {
|
|
const raw = window.localStorage.getItem(projectViewSettingsKey);
|
|
|
|
if (!raw) {
|
|
return defaultProjectViewSettings;
|
|
}
|
|
|
|
const parsed = JSON.parse(raw) as Partial<ProjectViewSettings>;
|
|
const filterValues = Array.isArray(parsed.filterValues)
|
|
? normalizedProjectFilterValues(parsed.filterValues)
|
|
: legacyProjectFilters(parsed as Partial<ProjectViewSettings> & Record<string, unknown>);
|
|
|
|
return {
|
|
query: typeof parsed.query === "string" ? parsed.query : defaultProjectViewSettings.query,
|
|
filterValues,
|
|
organizationValues: legacyProjectOrganizationValues(parsed as Partial<ProjectViewSettings> & Record<string, unknown>),
|
|
standardTeamspaceOnly: legacyStandardTeamspaceOnly(parsed as Partial<ProjectViewSettings> & Record<string, unknown>),
|
|
sortBy: projectSortValues.find((value) => value === parsed.sortBy) ?? defaultProjectViewSettings.sortBy,
|
|
sortDirection: parsed.sortDirection === "asc" ? "asc" : defaultProjectViewSettings.sortDirection
|
|
};
|
|
} catch {
|
|
return defaultProjectViewSettings;
|
|
}
|
|
}
|
|
|
|
function emptyProjectForm(): ProjectForm {
|
|
return {
|
|
ticketNumber: "",
|
|
title: "",
|
|
organizationId: "",
|
|
organizationName: null,
|
|
description: "",
|
|
startsOn: "",
|
|
endsOn: "",
|
|
teamspaceEntries: [emptyProjectTeamspaceEntry(true)]
|
|
};
|
|
}
|
|
|
|
function combinedProjectDescription(project: ProjectSummary) {
|
|
return [project.description, project.goals].map((part) => part.trim()).filter(Boolean).join("\n\n");
|
|
}
|
|
|
|
function formatHoursInput(minutes?: number | null) {
|
|
if (minutes === null || minutes === undefined) {
|
|
return "";
|
|
}
|
|
|
|
return new Intl.NumberFormat("de-DE", { maximumFractionDigits: 2 }).format(minutes / 60);
|
|
}
|
|
|
|
function formatDecimalHours(minutes: number) {
|
|
return `${new Intl.NumberFormat("de-DE", { maximumFractionDigits: 2 }).format(minutes / 60)} h`;
|
|
}
|
|
|
|
function projectTeamspaceEntries(project: ProjectSummary): ProjectTeamspaceEntry[] {
|
|
if (project.teamspace_entries?.length) {
|
|
return project.teamspace_entries;
|
|
}
|
|
|
|
return [
|
|
{
|
|
id: `${project.id}-default`,
|
|
teamspace_project: project.teamspace_project,
|
|
is_default: true,
|
|
hour_budget_minutes: project.hour_budget_minutes,
|
|
manual_consumption_minutes: project.manual_consumption_minutes ?? 0,
|
|
teamspace_consumption_minutes: project.teamspace_consumption_minutes ?? 0,
|
|
consumption_minutes: project.consumption_minutes ?? project.teamspace_consumption_minutes ?? 0,
|
|
display_order: 0
|
|
}
|
|
];
|
|
}
|
|
|
|
function budgetedProjectTeamspaceEntries(project: ProjectSummary) {
|
|
return projectTeamspaceEntries(project).filter((entry) => Number(entry.hour_budget_minutes ?? 0) > 0);
|
|
}
|
|
|
|
function projectEntryConsumption(entry: ProjectTeamspaceEntry) {
|
|
return Number(entry.consumption_minutes ?? 0);
|
|
}
|
|
|
|
function defaultProjectTeamspaceEntry(project: ProjectSummary) {
|
|
const entries = projectTeamspaceEntries(project);
|
|
|
|
return entries.find((entry) => entry.is_default) ?? entries[0] ?? null;
|
|
}
|
|
|
|
function projectEntryPercent(entry: ProjectTeamspaceEntry) {
|
|
if (!entry.hour_budget_minutes || entry.hour_budget_minutes <= 0) {
|
|
return null;
|
|
}
|
|
|
|
return Math.round((projectEntryConsumption(entry) / entry.hour_budget_minutes) * 100);
|
|
}
|
|
|
|
function projectConsumptionBarClass(percent: number) {
|
|
if (percent >= 100) {
|
|
return "bg-blue-500";
|
|
}
|
|
|
|
if (percent >= 90) {
|
|
return "bg-red-500";
|
|
}
|
|
|
|
return "bg-emerald-500";
|
|
}
|
|
|
|
function SortDirectionIcon({ direction }: { direction: ProjectSortDirection }) {
|
|
return direction === "asc" ? <ArrowUp className="size-3.5" /> : <ArrowDown className="size-3.5" />;
|
|
}
|
|
|
|
function parseHoursInput(value: string) {
|
|
const trimmed = value.trim();
|
|
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
|
|
const hours = Number(trimmed.replace(",", "."));
|
|
|
|
if (!Number.isFinite(hours) || hours < 0) {
|
|
return undefined;
|
|
}
|
|
|
|
return Math.round(hours * 60);
|
|
}
|
|
|
|
function compareText(left: string | null | undefined, right: string | null | undefined) {
|
|
return (left ?? "").localeCompare(right ?? "", "de", { numeric: true, sensitivity: "base" });
|
|
}
|
|
|
|
function projectOrganizationLabel(project: ProjectSummary) {
|
|
return project.organization_name ?? project.customer_name ?? "Keine Organisation";
|
|
}
|
|
|
|
function projectOrganizationKey(project: ProjectSummary) {
|
|
return project.organization_id ? `id:${project.organization_id}` : `name:${projectOrganizationLabel(project)}`;
|
|
}
|
|
|
|
function projectBudget(project: ProjectSummary, standardTeamspaceOnly = false) {
|
|
if (standardTeamspaceOnly) {
|
|
return defaultProjectTeamspaceEntry(project)?.hour_budget_minutes ?? null;
|
|
}
|
|
|
|
return project.hour_budget_minutes ?? null;
|
|
}
|
|
|
|
function projectConsumption(project: ProjectSummary, standardTeamspaceOnly = false) {
|
|
if (standardTeamspaceOnly) {
|
|
const defaultEntry = defaultProjectTeamspaceEntry(project);
|
|
|
|
return defaultEntry ? projectEntryConsumption(defaultEntry) : 0;
|
|
}
|
|
|
|
return Number(project.consumption_minutes ?? 0);
|
|
}
|
|
|
|
function projectRemaining(project: ProjectSummary, standardTeamspaceOnly = false) {
|
|
const budgetMinutes = projectBudget(project, standardTeamspaceOnly);
|
|
|
|
if (budgetMinutes === null || budgetMinutes === undefined) {
|
|
return null;
|
|
}
|
|
|
|
return budgetMinutes - projectConsumption(project, standardTeamspaceOnly);
|
|
}
|
|
|
|
function projectConsumptionPercent(project: ProjectSummary, standardTeamspaceOnly = false) {
|
|
const budgetMinutes = projectBudget(project, standardTeamspaceOnly);
|
|
|
|
if (!budgetMinutes || budgetMinutes <= 0) {
|
|
return null;
|
|
}
|
|
|
|
return Math.round((projectConsumption(project, standardTeamspaceOnly) / budgetMinutes) * 100);
|
|
}
|
|
|
|
function projectMatchesQuery(project: ProjectSummary, query: string) {
|
|
const normalized = query.trim().toLocaleLowerCase("de-DE");
|
|
|
|
if (!normalized) {
|
|
return true;
|
|
}
|
|
|
|
const values = [
|
|
project.ticket_number,
|
|
project.ticket_number.replace(/^Ticket#/i, ""),
|
|
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
|
|
];
|
|
|
|
return values.some((value) => value.toLocaleLowerCase("de-DE").includes(normalized));
|
|
}
|
|
|
|
function groupedProjectFilterMatch(filters: string[], groupForFilter: (filter: string) => string, matchesFilter: (filter: string) => boolean) {
|
|
if (filters.length === 0) {
|
|
return true;
|
|
}
|
|
|
|
const groups = new Map<string, string[]>();
|
|
|
|
for (const filter of filters) {
|
|
const group = groupForFilter(filter);
|
|
groups.set(group, [...(groups.get(group) ?? []), filter]);
|
|
}
|
|
|
|
return Array.from(groups.values()).every((groupFilters) => groupFilters.some((filter) => matchesFilter(filter)));
|
|
}
|
|
|
|
function projectFilterGroup(filter: string) {
|
|
if (filter === "open" || filter === "closed") {
|
|
return "status";
|
|
}
|
|
|
|
if (filter === "hidden") {
|
|
return "visibility";
|
|
}
|
|
|
|
if (filter.startsWith("zammadState:")) {
|
|
return "zammadState";
|
|
}
|
|
|
|
if (filter.startsWith("zammadPriority:")) {
|
|
return "zammadPriority";
|
|
}
|
|
|
|
return "budget";
|
|
}
|
|
|
|
function matchesSingleProjectFilter(project: ProjectSummary, filter: string, standardTeamspaceOnly = false) {
|
|
if (filter === "open") {
|
|
return !project.closed_at;
|
|
}
|
|
|
|
if (filter === "closed") {
|
|
return Boolean(project.closed_at);
|
|
}
|
|
|
|
if (filter === "hidden") {
|
|
return Boolean(project.hidden_for_user);
|
|
}
|
|
|
|
if (filter.startsWith("zammadState:")) {
|
|
return matchesZammadStateFilter(project.zammad_state_id, filter.slice("zammadState:".length) as Parameters<typeof matchesZammadStateFilter>[1]);
|
|
}
|
|
|
|
if (filter.startsWith("zammadPriority:")) {
|
|
return matchesZammadPriorityFilter(project.zammad_priority_id, filter.slice("zammadPriority:".length) as Parameters<typeof matchesZammadPriorityFilter>[1]);
|
|
}
|
|
|
|
const remaining = projectRemaining(project, standardTeamspaceOnly);
|
|
const budgetMinutes = projectBudget(project, standardTeamspaceOnly) ?? 0;
|
|
|
|
if (filter === "budgeted") {
|
|
return budgetMinutes > 0;
|
|
}
|
|
|
|
if (filter === "withoutBudget") {
|
|
return budgetMinutes <= 0;
|
|
}
|
|
|
|
if (filter === "remaining") {
|
|
return budgetMinutes > 0 && remaining !== null && remaining > 0;
|
|
}
|
|
|
|
if (filter === "overBudget") {
|
|
return budgetMinutes > 0 && remaining !== null && remaining < 0;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function matchesProjectFilters(project: ProjectSummary, filters: string[], organizationValues: string[], standardTeamspaceOnly = false) {
|
|
if (organizationValues.length > 0 && !organizationValues.includes(projectOrganizationKey(project))) {
|
|
return false;
|
|
}
|
|
|
|
return groupedProjectFilterMatch(filters, projectFilterGroup, (filter) => matchesSingleProjectFilter(project, filter, standardTeamspaceOnly));
|
|
}
|
|
|
|
function projectShouldLoadClosed(filters: string[]) {
|
|
const statusFilters = filters.filter((filter) => filter === "open" || filter === "closed");
|
|
|
|
return statusFilters.length === 0 || statusFilters.includes("closed");
|
|
}
|
|
|
|
function projectShouldLoadHidden(filters: string[]) {
|
|
return filters.includes("hidden");
|
|
}
|
|
|
|
function compareProjects(left: ProjectSummary, right: ProjectSummary, sortBy: ProjectSortBy, direction: ProjectSortDirection, standardTeamspaceOnly = false) {
|
|
const factor = direction === "asc" ? 1 : -1;
|
|
let comparison = 0;
|
|
|
|
if (sortBy === "ticket") {
|
|
comparison = compareText(left.ticket_number, right.ticket_number);
|
|
} else if (sortBy === "title") {
|
|
comparison = compareText(left.title || left.ticket_number, right.title || right.ticket_number);
|
|
} else if (sortBy === "organization") {
|
|
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, standardTeamspaceOnly) - projectConsumption(right, standardTeamspaceOnly);
|
|
} else if (sortBy === "consumptionPercent") {
|
|
const leftPercent = projectConsumptionPercent(left, standardTeamspaceOnly);
|
|
const rightPercent = projectConsumptionPercent(right, standardTeamspaceOnly);
|
|
|
|
if (leftPercent === null || rightPercent === null) {
|
|
return leftPercent === rightPercent ? compareText(left.ticket_number, right.ticket_number) : leftPercent === null ? 1 : -1;
|
|
}
|
|
|
|
comparison = leftPercent - rightPercent;
|
|
} else if (sortBy === "remaining") {
|
|
comparison = (projectRemaining(left, standardTeamspaceOnly) ?? Number.POSITIVE_INFINITY) - (projectRemaining(right, standardTeamspaceOnly) ?? Number.POSITIVE_INFINITY);
|
|
} else if (sortBy === "budget") {
|
|
comparison = (projectBudget(left, standardTeamspaceOnly) ?? Number.POSITIVE_INFINITY) - (projectBudget(right, standardTeamspaceOnly) ?? Number.POSITIVE_INFINITY);
|
|
} else {
|
|
comparison = new Date(left.updated_at).getTime() - new Date(right.updated_at).getTime();
|
|
}
|
|
|
|
return comparison * factor || compareText(left.ticket_number, right.ticket_number);
|
|
}
|
|
|
|
function formFromProject(project: ProjectSummary): ProjectForm {
|
|
const entries = projectTeamspaceEntries(project).map((entry, index) => ({
|
|
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
|
|
}));
|
|
const hasDefault = entries.some((entry) => entry.isDefault);
|
|
|
|
return {
|
|
ticketNumber: project.ticket_number,
|
|
title: project.title ?? "",
|
|
organizationId: project.organization_id ?? "",
|
|
organizationName: project.organization_name ?? project.customer_name ?? null,
|
|
description: combinedProjectDescription(project),
|
|
startsOn: project.starts_on ?? "",
|
|
endsOn: project.ends_on ?? "",
|
|
teamspaceEntries: hasDefault ? entries : entries.map((entry, index) => ({ ...entry, isDefault: index === 0 }))
|
|
};
|
|
}
|
|
|
|
type ProjectFormFieldsProps = {
|
|
form: ProjectForm;
|
|
setForm: (form: ProjectForm) => void;
|
|
organizationLocked: boolean;
|
|
lookupStatus: "idle" | "loading" | "found" | "not-found";
|
|
zammadLookupBusy?: boolean;
|
|
saving: boolean;
|
|
submitLabel: string;
|
|
onZammadLookup?: () => void;
|
|
onSubmit: (event: FormEvent) => void;
|
|
};
|
|
|
|
function normalizeProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[]) {
|
|
const source = entries.length ? entries : [emptyProjectTeamspaceEntry(true)];
|
|
const defaultIndex = Math.max(0, source.findIndex((entry) => entry.isDefault));
|
|
|
|
return source.map((entry, index) => ({
|
|
...entry,
|
|
isDefault: index === defaultIndex
|
|
}));
|
|
}
|
|
|
|
function parseProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[]) {
|
|
const normalizedEntries = normalizeProjectTeamspaceEntryForms(entries);
|
|
const parsedEntries = normalizedEntries.map((entry) => {
|
|
const hourBudgetMinutes = parseHoursInput(entry.hourBudget);
|
|
const manualConsumptionMinutes = parseHoursInput(entry.manualConsumption);
|
|
|
|
if (hourBudgetMinutes === undefined || manualConsumptionMinutes === undefined) {
|
|
return undefined;
|
|
}
|
|
|
|
return {
|
|
id: /^\d+$/.test(entry.clientId) ? entry.clientId : null,
|
|
teamspaceProject: entry.teamspaceProject.trim() || null,
|
|
isDefault: entry.isDefault,
|
|
hourBudgetMinutes,
|
|
budgetSyncDisabled: entry.budgetSyncDisabled,
|
|
manualConsumptionMinutes: manualConsumptionMinutes ?? 0
|
|
};
|
|
});
|
|
|
|
if (parsedEntries.some((entry) => entry === undefined)) {
|
|
return undefined;
|
|
}
|
|
|
|
return parsedEntries as Array<{
|
|
id?: string | null;
|
|
teamspaceProject: string | null;
|
|
isDefault: boolean;
|
|
hourBudgetMinutes: number | null;
|
|
budgetSyncDisabled: boolean;
|
|
manualConsumptionMinutes: number;
|
|
}>;
|
|
}
|
|
|
|
function defaultProjectTeamspacePayload(entries: ReturnType<typeof parseProjectTeamspaceEntryForms>) {
|
|
return entries?.find((entry) => entry.isDefault) ?? entries?.[0] ?? null;
|
|
}
|
|
|
|
function ProjectTeamspaceEntriesFields({
|
|
entries,
|
|
onChange
|
|
}: {
|
|
entries: ProjectTeamspaceEntryForm[];
|
|
onChange: (entries: ProjectTeamspaceEntryForm[]) => void;
|
|
}) {
|
|
const normalizedEntries = normalizeProjectTeamspaceEntryForms(entries);
|
|
|
|
function updateEntry(clientId: string, changes: Partial<ProjectTeamspaceEntryForm>) {
|
|
onChange(normalizeProjectTeamspaceEntryForms(normalizedEntries.map((entry) => (entry.clientId === clientId ? { ...entry, ...changes } : entry))));
|
|
}
|
|
|
|
function setDefault(clientId: string) {
|
|
onChange(normalizedEntries.map((entry) => ({ ...entry, isDefault: entry.clientId === clientId })));
|
|
}
|
|
|
|
function removeEntry(clientId: string) {
|
|
onChange(normalizeProjectTeamspaceEntryForms(normalizedEntries.filter((entry) => entry.clientId !== clientId)));
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<label className="text-sm font-medium">Teamspace-Einträge</label>
|
|
<Button type="button" size="sm" variant="secondary" onClick={() => onChange([...normalizedEntries, emptyProjectTeamspaceEntry(false)])}>
|
|
<Plus className="size-3.5" />
|
|
Eintrag
|
|
</Button>
|
|
</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_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)" : ""}
|
|
</label>
|
|
<Input
|
|
id={`project-teamspace-${entry.clientId}`}
|
|
value={entry.teamspaceProject}
|
|
onChange={(event) => updateEntry(entry.clientId, { teamspaceProject: event.currentTarget.value })}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-muted-foreground" htmlFor={`project-budget-${entry.clientId}`}>
|
|
Budget h
|
|
</label>
|
|
<Input
|
|
id={`project-budget-${entry.clientId}`}
|
|
inputMode="decimal"
|
|
value={entry.hourBudget}
|
|
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
|
|
</label>
|
|
<Input
|
|
id={`project-manual-${entry.clientId}`}
|
|
inputMode="decimal"
|
|
value={entry.manualConsumption}
|
|
onChange={(event) => updateEntry(entry.clientId, { manualConsumption: event.currentTarget.value })}
|
|
/>
|
|
</div>
|
|
<Button type="button" size="sm" variant={entry.isDefault ? "default" : "secondary"} onClick={() => setDefault(entry.clientId)}>
|
|
{entry.isDefault ? "Standard" : "Als Standard"}
|
|
</Button>
|
|
<Button type="button" size="icon" variant="ghost" disabled={normalizedEntries.length === 1} onClick={() => removeEntry(entry.clientId)}>
|
|
<Trash2 className="size-4 text-destructive" />
|
|
<span className="sr-only">Teamspace-Eintrag {index + 1} löschen</span>
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
Der Standard-Eintrag bekommt die aus Teamspace gebuchten Tageswerte. Manueller Verbrauch zählt nur im Projektverbrauch.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ProjectFormFields({ form, setForm, organizationLocked, lookupStatus, zammadLookupBusy = false, saving, submitLabel, onZammadLookup, onSubmit }: ProjectFormFieldsProps) {
|
|
return (
|
|
<form className="space-y-4" onSubmit={onSubmit}>
|
|
<div className="grid gap-3 lg:grid-cols-[180px_minmax(240px,1fr)_180px]">
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium" htmlFor="project-ticket-number">
|
|
Ticket
|
|
</label>
|
|
<Input
|
|
id="project-ticket-number"
|
|
placeholder="Ticket#123456"
|
|
value={form.ticketNumber}
|
|
onChange={(event) =>
|
|
setForm({
|
|
...form,
|
|
ticketNumber: event.currentTarget.value,
|
|
organizationId: "",
|
|
organizationName: null
|
|
})
|
|
}
|
|
required
|
|
/>
|
|
{form.ticketNumber && !ticketPattern.test(form.ticketNumber.trim()) ? <p className="text-xs text-destructive">Format: Ticket#XXXXXX</p> : null}
|
|
{lookupStatus === "loading" ? <p className="text-xs text-muted-foreground">Suche Ticket...</p> : null}
|
|
{lookupStatus === "found" ? <p className="text-xs text-muted-foreground">Ticket gefunden. Daten werden übernommen.</p> : null}
|
|
{lookupStatus === "not-found" ? (
|
|
<div className="space-y-2">
|
|
<p className="text-xs text-muted-foreground">Neues Ticket. Organisation bitte wählen oder aus Zammad übernehmen.</p>
|
|
{onZammadLookup ? (
|
|
<Button type="button" size="sm" variant="secondary" disabled={zammadLookupBusy} onClick={onZammadLookup}>
|
|
<Search className="size-3.5" />
|
|
{zammadLookupBusy ? "Sucht..." : "In Zammad suchen"}
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium" htmlFor="project-title">
|
|
Titel
|
|
</label>
|
|
<Input id="project-title" value={form.title} onChange={(event) => setForm({ ...form, title: event.currentTarget.value })} />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium">Art</label>
|
|
<div className="flex h-9 items-center rounded-lg border bg-muted/30 px-3 text-sm">Consulting</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-3 lg:grid-cols-[minmax(240px,1fr)_320px_320px]">
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium">Organisation</label>
|
|
<OrganizationSelect
|
|
value={form.organizationId}
|
|
selectedName={form.organizationName}
|
|
disabled={organizationLocked}
|
|
onChange={(organization) => setForm({ ...form, organizationId: organization.id, organizationName: organization.name })}
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium" htmlFor="project-starts-on">
|
|
Zeitraum von
|
|
</label>
|
|
<Input id="project-starts-on" type="date" value={form.startsOn} onChange={(event) => setForm({ ...form, startsOn: event.currentTarget.value })} />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium" htmlFor="project-ends-on">
|
|
Zeitraum bis
|
|
</label>
|
|
<Input id="project-ends-on" type="date" value={form.endsOn} onChange={(event) => setForm({ ...form, endsOn: event.currentTarget.value })} />
|
|
</div>
|
|
</div>
|
|
|
|
<ProjectTeamspaceEntriesFields entries={form.teamspaceEntries} onChange={(teamspaceEntries) => setForm({ ...form, teamspaceEntries })} />
|
|
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium" htmlFor="project-description">
|
|
Beschreibung/Ziele
|
|
</label>
|
|
<Textarea id="project-description" className="min-h-28" value={form.description} onChange={(event) => setForm({ ...form, description: event.currentTarget.value })} />
|
|
</div>
|
|
|
|
<div className="flex justify-end">
|
|
<Button type="submit" disabled={saving}>
|
|
<Save className="size-4" />
|
|
{saving ? "Speichert..." : submitLabel}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
|
const [projects, setProjects] = useState<ProjectSummary[]>([]);
|
|
const [hiddenProjectCount, setHiddenProjectCount] = useState(0);
|
|
const [totalProjectCount, setTotalProjectCount] = useState(0);
|
|
const [loading, setLoading] = useState(true);
|
|
const [form, setForm] = useState<ProjectForm>(() => emptyProjectForm());
|
|
const [lookupStatus, setLookupStatus] = useState<"idle" | "loading" | "found" | "not-found">("idle");
|
|
const [lookupResult, setLookupResult] = useState<TicketMeta | null>(null);
|
|
const [zammadLookupBusy, setZammadLookupBusy] = useState(false);
|
|
const [useZammadForCreate, setUseZammadForCreate] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
const [createOpen, setCreateOpen] = useState(false);
|
|
const [editingProjectId, setEditingProjectId] = useState<string | null>(null);
|
|
const [editForm, setEditForm] = useState<ProjectForm>(() => emptyProjectForm());
|
|
const [savingProjectId, setSavingProjectId] = useState<string | null>(null);
|
|
const [savingProjectStatusId, setSavingProjectStatusId] = useState<string | null>(null);
|
|
const [viewSettings, setViewSettings] = useState<ProjectViewSettings>(() => readStoredProjectViewSettings());
|
|
|
|
async function load() {
|
|
setLoading(true);
|
|
try {
|
|
const result = await getProjects(projectShouldLoadClosed(viewSettings.filterValues), projectShouldLoadHidden(viewSettings.filterValues));
|
|
setProjects(result.projects);
|
|
setHiddenProjectCount(result.hiddenCount ?? 0);
|
|
setTotalProjectCount(result.totalCount ?? result.projects.length);
|
|
} catch (error) {
|
|
toast.error("Projekte konnten nicht geladen werden", {
|
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
|
});
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
void load();
|
|
}, [viewSettings.filterValues]);
|
|
|
|
useEffect(() => {
|
|
window.localStorage.setItem(projectViewSettingsKey, JSON.stringify(viewSettings));
|
|
}, [viewSettings]);
|
|
|
|
useEffect(() => {
|
|
const normalizedTicketNumber = form.ticketNumber.trim();
|
|
|
|
if (!ticketPattern.test(normalizedTicketNumber)) {
|
|
setLookupStatus("idle");
|
|
setLookupResult(null);
|
|
setUseZammadForCreate(false);
|
|
return;
|
|
}
|
|
|
|
let cancelled = false;
|
|
setLookupStatus("loading");
|
|
|
|
const timeout = window.setTimeout(async () => {
|
|
try {
|
|
const result = await lookupTicket(normalizedTicketNumber);
|
|
|
|
if (cancelled) {
|
|
return;
|
|
}
|
|
|
|
setLookupResult(result.ticket);
|
|
setLookupStatus(result.ticket ? "found" : "not-found");
|
|
setUseZammadForCreate(false);
|
|
|
|
if (result.ticket?.organization_id) {
|
|
setForm((current) =>
|
|
current.ticketNumber.trim() === normalizedTicketNumber
|
|
? {
|
|
...current,
|
|
title: current.title || result.ticket?.title || "",
|
|
organizationId: result.ticket?.organization_id ?? "",
|
|
organizationName: result.ticket?.organization_name ?? result.ticket?.customer_name ?? null
|
|
}
|
|
: current
|
|
);
|
|
}
|
|
} catch {
|
|
if (!cancelled) {
|
|
setLookupStatus("idle");
|
|
setLookupResult(null);
|
|
}
|
|
}
|
|
}, 250);
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
window.clearTimeout(timeout);
|
|
};
|
|
}, [form.ticketNumber]);
|
|
|
|
const organizationLocked = Boolean(lookupResult?.organization_id);
|
|
const openProjects = useMemo(() => projects.filter((project) => !project.closed_at), [projects]);
|
|
const closedProjects = useMemo(() => projects.filter((project) => project.closed_at), [projects]);
|
|
const organizationOptions = useMemo(() => {
|
|
const options = new Map<string, string>();
|
|
|
|
projects.forEach((project) => {
|
|
options.set(projectOrganizationKey(project), projectOrganizationLabel(project));
|
|
});
|
|
|
|
return Array.from(options.entries())
|
|
.map(([value, label]) => ({ value, label }))
|
|
.sort((left, right) => compareText(left.label, right.label));
|
|
}, [projects]);
|
|
const filteredProjects = useMemo(() => {
|
|
return projects
|
|
.filter((project) => {
|
|
if (!projectMatchesQuery(project, viewSettings.query)) {
|
|
return false;
|
|
}
|
|
|
|
return matchesProjectFilters(project, viewSettings.filterValues, viewSettings.organizationValues, viewSettings.standardTeamspaceOnly);
|
|
})
|
|
.sort((left, right) => compareProjects(left, right, viewSettings.sortBy, viewSettings.sortDirection, viewSettings.standardTeamspaceOnly));
|
|
}, [projects, viewSettings]);
|
|
const projectConsumptionMinutes = useMemo(
|
|
() => filteredProjects.reduce((sum, project) => sum + projectConsumption(project, viewSettings.standardTeamspaceOnly), 0),
|
|
[filteredProjects, viewSettings.standardTeamspaceOnly]
|
|
);
|
|
const remainingMinutes = useMemo(
|
|
() => filteredProjects.reduce((sum, project) => sum + Math.max(projectRemaining(project, viewSettings.standardTeamspaceOnly) ?? 0, 0), 0),
|
|
[filteredProjects, viewSettings.standardTeamspaceOnly]
|
|
);
|
|
const closedProjectsLoaded = projectShouldLoadClosed(viewSettings.filterValues);
|
|
const activeFilterCount = [
|
|
viewSettings.query.trim() !== "",
|
|
JSON.stringify(viewSettings.filterValues) !== JSON.stringify(defaultProjectViewSettings.filterValues),
|
|
viewSettings.organizationValues.length > 0,
|
|
viewSettings.standardTeamspaceOnly,
|
|
viewSettings.sortBy !== defaultProjectViewSettings.sortBy,
|
|
viewSettings.sortDirection !== defaultProjectViewSettings.sortDirection
|
|
].filter(Boolean).length;
|
|
|
|
async function lookupProjectTicketInZammad() {
|
|
const ticketNumber = form.ticketNumber.trim();
|
|
|
|
if (!ticketPattern.test(ticketNumber)) {
|
|
toast.error("Ticketnummer prüfen", {
|
|
description: "Das Format muss Ticket#XXXXXX sein."
|
|
});
|
|
return;
|
|
}
|
|
|
|
setZammadLookupBusy(true);
|
|
|
|
try {
|
|
const result = await lookupTicket(ticketNumber, "zammad");
|
|
|
|
if (!result.ticket) {
|
|
setLookupStatus("not-found");
|
|
toast.error("Ticket nicht gefunden", {
|
|
description: "Zammad hat zu dieser Ticketnummer keinen Treffer geliefert."
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (result.ticket.work_type && result.ticket.work_type !== "consulting") {
|
|
toast.error("Kein Consulting-Ticket", {
|
|
description: "Für Support-Tickets wird kein Projekt angelegt."
|
|
});
|
|
return;
|
|
}
|
|
|
|
setLookupResult(result.ticket);
|
|
setLookupStatus("found");
|
|
setUseZammadForCreate(true);
|
|
setForm((current) => {
|
|
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,
|
|
title: current.title || result.ticket?.title || "",
|
|
organizationId: result.ticket?.organization_id ?? "",
|
|
organizationName: result.ticket?.organization_name ?? result.ticket?.customer_name ?? null,
|
|
teamspaceEntries: nextEntries
|
|
};
|
|
});
|
|
toast.success("Zammad-Daten übernommen");
|
|
} catch (error) {
|
|
toast.error("Zammad-Suche fehlgeschlagen", {
|
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
|
});
|
|
} finally {
|
|
setZammadLookupBusy(false);
|
|
}
|
|
}
|
|
|
|
function setProjectSort(sortBy: ProjectSortBy) {
|
|
setViewSettings((current) => ({
|
|
...current,
|
|
sortBy,
|
|
sortDirection: current.sortBy === sortBy ? (current.sortDirection === "asc" ? "desc" : "asc") : current.sortDirection
|
|
}));
|
|
}
|
|
|
|
function sortHeader(label: string, sortBy: ProjectSortBy) {
|
|
const active = viewSettings.sortBy === sortBy;
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
className={cn("inline-flex min-w-0 items-center gap-1 text-left hover:text-foreground", active ? "text-foreground" : undefined)}
|
|
onClick={() => setProjectSort(sortBy)}
|
|
>
|
|
<span className="truncate">{label}</span>
|
|
{active ? <SortDirectionIcon direction={viewSettings.sortDirection} /> : null}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
async function submitProject(event: FormEvent) {
|
|
event.preventDefault();
|
|
|
|
const ticketNumber = form.ticketNumber.trim();
|
|
|
|
if (!ticketPattern.test(ticketNumber)) {
|
|
toast.error("Ticketnummer prüfen", {
|
|
description: "Das Format muss Ticket#XXXXXX sein."
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (!form.organizationId) {
|
|
toast.error("Organisation wählen", {
|
|
description: "Wenn das Ticket noch keine Stammdaten hat, muss die Organisation gesetzt werden."
|
|
});
|
|
return;
|
|
}
|
|
|
|
const teamspaceEntries = parseProjectTeamspaceEntryForms(form.teamspaceEntries);
|
|
|
|
if (!teamspaceEntries) {
|
|
toast.error("Teamspace-Einträge prüfen", {
|
|
description: "Bitte Stunden als Zahl eintragen, z.B. 12,5."
|
|
});
|
|
return;
|
|
}
|
|
|
|
const defaultTeamspaceEntry = defaultProjectTeamspacePayload(teamspaceEntries);
|
|
|
|
setSaving(true);
|
|
try {
|
|
const result = await createProject({
|
|
ticketNumber,
|
|
title: form.title || null,
|
|
organizationId: form.organizationId,
|
|
zammadLookup: useZammadForCreate,
|
|
teamspaceProject: defaultTeamspaceEntry?.teamspaceProject ?? null,
|
|
teamspaceEntries,
|
|
description: form.description,
|
|
goals: "",
|
|
startsOn: form.startsOn || null,
|
|
endsOn: form.endsOn || null,
|
|
hourBudgetMinutes: defaultTeamspaceEntry?.hourBudgetMinutes ?? null,
|
|
manualConsumptionMinutes: defaultTeamspaceEntry?.manualConsumptionMinutes ?? 0
|
|
});
|
|
toast.success("Projekt angelegt", {
|
|
description: `${result.project.ticket_number} ist jetzt als Consulting-Projekt markiert.`
|
|
});
|
|
setForm(emptyProjectForm());
|
|
setCreateOpen(false);
|
|
setLookupResult(null);
|
|
setLookupStatus("idle");
|
|
setUseZammadForCreate(false);
|
|
await load();
|
|
} catch (error) {
|
|
toast.error("Projekt konnte nicht angelegt werden", {
|
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
|
});
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
async function saveProject(project: ProjectSummary) {
|
|
const teamspaceEntries = parseProjectTeamspaceEntryForms(editForm.teamspaceEntries);
|
|
|
|
if (!teamspaceEntries) {
|
|
toast.error("Teamspace-Einträge prüfen", {
|
|
description: "Bitte Stunden als Zahl eintragen, z.B. 12,5."
|
|
});
|
|
return;
|
|
}
|
|
|
|
const defaultTeamspaceEntry = defaultProjectTeamspacePayload(teamspaceEntries);
|
|
|
|
if (!editForm.organizationId) {
|
|
toast.error("Organisation wählen");
|
|
return;
|
|
}
|
|
|
|
setSavingProjectId(project.id);
|
|
try {
|
|
await updateProject(project.id, {
|
|
ticketNumber: editForm.ticketNumber,
|
|
title: editForm.title || null,
|
|
organizationId: editForm.organizationId,
|
|
teamspaceProject: defaultTeamspaceEntry?.teamspaceProject ?? null,
|
|
teamspaceEntries,
|
|
description: editForm.description,
|
|
goals: "",
|
|
startsOn: editForm.startsOn || null,
|
|
endsOn: editForm.endsOn || null,
|
|
hourBudgetMinutes: defaultTeamspaceEntry?.hourBudgetMinutes ?? null,
|
|
manualConsumptionMinutes: defaultTeamspaceEntry?.manualConsumptionMinutes ?? 0
|
|
});
|
|
toast.success("Projekt gespeichert");
|
|
setEditingProjectId(null);
|
|
await load();
|
|
} catch (error) {
|
|
toast.error("Projekt konnte nicht gespeichert werden", {
|
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
|
});
|
|
} finally {
|
|
setSavingProjectId(null);
|
|
}
|
|
}
|
|
|
|
async function setProjectClosed(project: ProjectSummary, closed: boolean) {
|
|
setSavingProjectStatusId(project.id);
|
|
try {
|
|
await updateProjectStatus(project.id, closed);
|
|
toast.success(closed ? "Projekt geschlossen" : "Projekt wieder geöffnet");
|
|
await load();
|
|
} catch (error) {
|
|
toast.error("Projektstatus konnte nicht gespeichert werden", {
|
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
|
});
|
|
} finally {
|
|
setSavingProjectStatusId(null);
|
|
}
|
|
}
|
|
|
|
async function saveProjectFavorite(project: ProjectSummary) {
|
|
try {
|
|
await addFavoriteTicket(project.ticket_number);
|
|
toast.success("Favorit gespeichert", {
|
|
description: project.ticket_number
|
|
});
|
|
} catch (error) {
|
|
toast.error("Favorit konnte nicht gespeichert werden", {
|
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
|
});
|
|
}
|
|
}
|
|
|
|
async function hideProjectForUser(project: ProjectSummary) {
|
|
try {
|
|
await hideProject(project.id);
|
|
toast.success("Projekt ausgeblendet", {
|
|
description: project.ticket_number
|
|
});
|
|
await load();
|
|
} catch (error) {
|
|
toast.error("Projekt konnte nicht ausgeblendet werden", {
|
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
|
});
|
|
}
|
|
}
|
|
|
|
async function unhideProjectForUser(project: ProjectSummary) {
|
|
try {
|
|
await unhideProject(project.id);
|
|
toast.success("Projekt eingeblendet", {
|
|
description: project.ticket_number
|
|
});
|
|
await load();
|
|
} catch (error) {
|
|
toast.error("Projekt konnte nicht eingeblendet werden", {
|
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
|
});
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
|
<div>
|
|
<h2 className="text-xl font-semibold tracking-normal sm:text-2xl">Projekte</h2>
|
|
<p className="text-sm text-muted-foreground">Consulting-Projekte als Erweiterung bestehender oder neuer Tickets.</p>
|
|
</div>
|
|
<div className="flex min-w-0 items-center justify-end gap-2">
|
|
<div className="flex min-w-0 flex-nowrap gap-2 overflow-x-auto pb-1 text-sm xxl:overflow-visible">
|
|
<div className="w-28 shrink-0 rounded-md border bg-muted/20 px-3 py-2">
|
|
<span className="block text-muted-foreground">Offen</span>
|
|
<span className="font-semibold">{openProjects.length}</span>
|
|
</div>
|
|
<div className="w-28 shrink-0 rounded-md border bg-muted/20 px-3 py-2">
|
|
<span className="block text-muted-foreground">Geschlossen</span>
|
|
<span className="font-semibold">{closedProjectsLoaded ? closedProjects.length : "-"}</span>
|
|
</div>
|
|
<div className="w-32 shrink-0 rounded-md border bg-muted/20 px-3 py-2">
|
|
<span className="block text-muted-foreground">Verbrauch</span>
|
|
<span className="font-semibold">{formatMinutes(projectConsumptionMinutes)}</span>
|
|
</div>
|
|
<div className="w-32 shrink-0 rounded-md border bg-muted/20 px-3 py-2">
|
|
<span className="block text-muted-foreground">Rest offen</span>
|
|
<span className="font-semibold">{formatMinutes(remainingMinutes)}</span>
|
|
</div>
|
|
</div>
|
|
<HelpLink anchor="projekte" label="Hilfe zu Projekten" className="shrink-0" />
|
|
</div>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardHeader className="p-4">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div>
|
|
<CardTitle className="flex items-center gap-2 text-base">
|
|
<SlidersHorizontal className="size-4 text-muted-foreground" />
|
|
Filter und Sortierung
|
|
{activeFilterCount > 0 ? <Badge variant="secondary">{activeFilterCount}</Badge> : null}
|
|
</CardTitle>
|
|
<CardDescription>{filteredProjects.length} von {totalProjectCount} Projekt(en) sichtbar.</CardDescription>
|
|
</div>
|
|
<Button type="button" size="sm" variant="ghost" onClick={() => setViewSettings(defaultProjectViewSettings)}>
|
|
<RotateCcw className="size-4" />
|
|
Zurücksetzen
|
|
</Button>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="grid gap-3 px-4 pb-4 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5">
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-muted-foreground" htmlFor="project-search">
|
|
Suche
|
|
</label>
|
|
<Input
|
|
id="project-search"
|
|
className="h-8"
|
|
placeholder="Ticket, Titel, Organisation, Teamspace..."
|
|
value={viewSettings.query}
|
|
onChange={(event) => {
|
|
const query = event.currentTarget.value;
|
|
setViewSettings((current) => ({ ...current, query }));
|
|
}}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-muted-foreground">Filter</label>
|
|
<MultiFilterDropdown
|
|
values={viewSettings.filterValues}
|
|
options={projectStaticFilterOptions}
|
|
onChange={(filterValues) => setViewSettings((current) => ({ ...current, filterValues }))}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-muted-foreground">Organisationen</label>
|
|
<MultiFilterDropdown
|
|
values={viewSettings.organizationValues}
|
|
options={organizationOptions}
|
|
onChange={(organizationValues) => setViewSettings((current) => ({ ...current, organizationValues }))}
|
|
placeholder="Alle Org."
|
|
countLabel="Org."
|
|
emptyLabel="Keine Org."
|
|
/>
|
|
</div>
|
|
<label className={cn("flex min-w-0 items-center gap-3 rounded-md border px-3 py-2", viewSettings.standardTeamspaceOnly ? "bg-muted/30" : "bg-background")}>
|
|
<Checkbox
|
|
checked={viewSettings.standardTeamspaceOnly}
|
|
onCheckedChange={(checked) => setViewSettings((current) => ({ ...current, standardTeamspaceOnly: checked === true }))}
|
|
/>
|
|
<span className="min-w-0">
|
|
<span className="block truncate text-xs font-medium text-muted-foreground">Nur Standard-TS</span>
|
|
<span className="block truncate text-xs">Hauptbalken</span>
|
|
</span>
|
|
</label>
|
|
<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 }))}>
|
|
<SelectTrigger size="sm" className="w-full">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{projectSortOptions.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">Richtung</label>
|
|
<div className="grid grid-cols-2 rounded-md border bg-background p-0.5">
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant={viewSettings.sortDirection === "asc" ? "secondary" : "ghost"}
|
|
className="h-7 px-2"
|
|
onClick={() => setViewSettings((current) => ({ ...current, sortDirection: "asc" }))}
|
|
>
|
|
<ArrowUp className="size-3.5" />
|
|
Auf
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant={viewSettings.sortDirection === "desc" ? "secondary" : "ghost"}
|
|
className="h-7 px-2"
|
|
onClick={() => setViewSettings((current) => ({ ...current, sortDirection: "desc" }))}
|
|
>
|
|
<ArrowDown className="size-3.5" />
|
|
Ab
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
{hiddenProjectCount > 0 && !projectShouldLoadHidden(viewSettings.filterValues) ? (
|
|
<div className="flex flex-col gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-800 dark:text-amber-200 sm:flex-row sm:items-center sm:justify-between">
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
<EyeOff className="size-4 shrink-0" />
|
|
<span className="min-w-0">
|
|
{hiddenProjectCount} Projekt(e) sind ausgeblendet.
|
|
</span>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="secondary"
|
|
className="shrink-0"
|
|
onClick={() => setViewSettings((current) => ({ ...current, filterValues: ["hidden"] }))}
|
|
>
|
|
Anzeigen
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
<Card className="overflow-hidden">
|
|
<div className="flex items-center justify-between gap-3 border-b px-4 py-3">
|
|
<div className="min-w-0">
|
|
<h3 className="text-sm font-medium">Projektliste</h3>
|
|
<p className="text-xs text-muted-foreground">{filteredProjects.length} Projekt(e)</p>
|
|
</div>
|
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
|
<DialogTrigger asChild>
|
|
<Button type="button" className="shrink-0">
|
|
<Plus className="size-4" />
|
|
Projekt
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-5xl">
|
|
<DialogHeader>
|
|
<DialogTitle>Projekt anlegen</DialogTitle>
|
|
<DialogDescription>Consulting-Projekt mit einem oder mehreren Teamspace-Einträgen anlegen.</DialogDescription>
|
|
</DialogHeader>
|
|
<ProjectFormFields
|
|
form={form}
|
|
setForm={setForm}
|
|
organizationLocked={organizationLocked}
|
|
lookupStatus={lookupStatus}
|
|
zammadLookupBusy={zammadLookupBusy}
|
|
saving={saving}
|
|
submitLabel="Projekt anlegen"
|
|
onZammadLookup={() => void lookupProjectTicketInZammad()}
|
|
onSubmit={submitProject}
|
|
/>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
<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")}
|
|
<span className="text-right">Aktion</span>
|
|
</div>
|
|
{filteredProjects.map((project) => {
|
|
const isEditing = editingProjectId === project.id;
|
|
const budgetedTeamspaceEntries = budgetedProjectTeamspaceEntries(project);
|
|
const consumptionMinutes = projectConsumption(project, viewSettings.standardTeamspaceOnly);
|
|
const budgetMinutes = projectBudget(project, viewSettings.standardTeamspaceOnly);
|
|
const remaining = projectRemaining(project, viewSettings.standardTeamspaceOnly);
|
|
const usedPercent = projectConsumptionPercent(project, viewSettings.standardTeamspaceOnly);
|
|
const showBudgetedTeamspaceEntries = !viewSettings.standardTeamspaceOnly && budgetedTeamspaceEntries.length > 1;
|
|
const canEditProject = project.can_edit !== false;
|
|
|
|
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(170px,1fr)_105px_115px_105px_165px_90px_44px] xl:items-center">
|
|
<div className="min-w-0">
|
|
<button
|
|
type="button"
|
|
className="block max-w-full truncate text-left text-xs font-medium text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
|
|
onClick={() => onNavigate(`/tickets/${project.ticket_id}?month=${currentMonth()}`)}
|
|
>
|
|
{project.ticket_number}
|
|
</button>
|
|
<p className="truncate text-sm font-normal">{project.title || "Kein Titel"}</p>
|
|
</div>
|
|
<div className="min-w-0 text-sm">
|
|
<p className="truncate font-medium xl:hidden">Organisation</p>
|
|
<p className="truncate">{projectOrganizationLabel(project)}</p>
|
|
</div>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
<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)} / {budgetMinutes ? formatDecimalHours(budgetMinutes) : "offen"}
|
|
</p>
|
|
{showBudgetedTeamspaceEntries ? <p className="text-xs text-muted-foreground">{budgetedTeamspaceEntries.length} Budgets</p> : null}
|
|
</div>
|
|
<div className="min-w-0 text-sm">
|
|
<p className="truncate font-medium xl:hidden">Verbrauch %</p>
|
|
<p className={cn("font-semibold", usedPercent !== null && usedPercent >= 100 ? "text-blue-500" : usedPercent !== null && usedPercent >= 90 ? "text-red-500" : undefined)}>
|
|
{usedPercent !== null ? `${usedPercent}%` : "-"}
|
|
</p>
|
|
</div>
|
|
<div className="flex justify-end">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button type="button" size="icon" variant="ghost" className="size-8">
|
|
<MoreHorizontal className="size-4" />
|
|
<span className="sr-only">Projektaktionen öffnen</span>
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-48">
|
|
{!projectHasRecurringBilling(project) ? (
|
|
<DropdownMenuItem className="hover:cursor-pointer" onSelect={() => void onStartTimer(project.ticket_number)}>
|
|
<Play className="size-4" />
|
|
Session starten
|
|
</DropdownMenuItem>
|
|
) : null}
|
|
<DropdownMenuItem className="hover:cursor-pointer" onSelect={() => void saveProjectFavorite(project)}>
|
|
<Star className="size-4" />
|
|
Als Favorit speichern
|
|
</DropdownMenuItem>
|
|
{project.hidden_for_user ? (
|
|
<DropdownMenuItem className="hover:cursor-pointer" onSelect={() => void unhideProjectForUser(project)}>
|
|
<Eye className="size-4" />
|
|
Einblenden
|
|
</DropdownMenuItem>
|
|
) : (
|
|
<DropdownMenuItem className="hover:cursor-pointer" onSelect={() => void hideProjectForUser(project)}>
|
|
<EyeOff className="size-4" />
|
|
Ausblenden
|
|
</DropdownMenuItem>
|
|
)}
|
|
<DropdownMenuItem className="hover:cursor-pointer" onSelect={() => onNavigate(`/tickets/${project.ticket_id}?month=${currentMonth()}`)}>
|
|
<Search className="size-4" />
|
|
Projekt öffnen
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
disabled={!canEditProject || savingProjectStatusId === project.id}
|
|
className="hover:cursor-pointer"
|
|
onSelect={() => void setProjectClosed(project, !project.closed_at)}
|
|
>
|
|
{project.closed_at ? <RotateCcw className="size-4" /> : <CheckCircle2 className="size-4" />}
|
|
{project.closed_at ? "Projekt öffnen" : "Projekt schließen"}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
disabled={!canEditProject}
|
|
className="hover:cursor-pointer"
|
|
onSelect={() => {
|
|
setEditingProjectId(project.id);
|
|
setEditForm(formFromProject(project));
|
|
}}
|
|
>
|
|
<Pencil className="size-4" />
|
|
Bearbeiten
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-2 px-4 pb-3">
|
|
{usedPercent !== null ? (
|
|
<div className="h-1.5 overflow-hidden rounded-full bg-muted" title={`${usedPercent}% Verbrauch`} aria-label={`${usedPercent}% Verbrauch`}>
|
|
<div className={cn("h-full rounded-full", projectConsumptionBarClass(usedPercent))} style={{ width: `${Math.min(100, usedPercent)}%` }} />
|
|
</div>
|
|
) : (
|
|
<div className="h-1.5 overflow-hidden rounded-full bg-muted/30">
|
|
<div className="h-full rounded-full bg-muted-foreground/10" style={{ width: "0%" }} />
|
|
</div>
|
|
)}
|
|
{(project.unassigned_teamspace_session_count ?? 0) > 0 ? (
|
|
<p className="rounded-md border border-amber-500/30 bg-amber-500/10 px-2 py-1 text-xs text-amber-700 dark:text-amber-300">
|
|
{project.unassigned_teamspace_session_count} Session(s) haben keinen Teamspace-Eintrag.
|
|
</p>
|
|
) : null}
|
|
{project.close_review_required ? (
|
|
<p className="rounded-md border border-amber-500/30 bg-amber-500/10 px-2 py-1 text-xs text-amber-700 dark:text-amber-300">
|
|
Zammad meldet das Consulting-Ticket als geschlossen. Bitte Zeiten in Teamspace und TicketTracker prüfen und das Projekt danach schließen.
|
|
</p>
|
|
) : null}
|
|
{showBudgetedTeamspaceEntries ? (
|
|
<div className="grid gap-1.5 md:grid-cols-2">
|
|
{budgetedTeamspaceEntries.map((entry) => {
|
|
const entryPercent = projectEntryPercent(entry);
|
|
const entryConsumption = projectEntryConsumption(entry);
|
|
const label = entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID");
|
|
|
|
return (
|
|
<div key={entry.id} className="min-w-0 rounded-md border bg-background/50 px-2 py-1">
|
|
<div className="mb-1 flex min-w-0 items-center justify-between gap-2 text-[11px]">
|
|
<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"}
|
|
</span>
|
|
</div>
|
|
<div className="h-1 overflow-hidden rounded-full bg-muted">
|
|
<div
|
|
className={cn("h-full rounded-full", entryPercent === null ? "bg-muted-foreground/20" : projectConsumptionBarClass(entryPercent))}
|
|
style={{ width: `${entryPercent === null ? 0 : Math.min(100, entryPercent)}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
{isEditing ? (
|
|
<div className="border-t bg-muted/10 p-4">
|
|
<div className="space-y-3">
|
|
{/* <div className="grid gap-3 md:grid-cols-[160px_minmax(200px,1fr)_160px]">
|
|
<Input value={editForm.ticketNumber} onChange={(event) => setEditForm({ ...editForm, ticketNumber: event.currentTarget.value })} />
|
|
<OrganizationSelect
|
|
value={editForm.organizationId}
|
|
selectedName={editForm.organizationName}
|
|
onChange={(organization) => setEditForm({ ...editForm, organizationId: organization.id, organizationName: organization.name })}
|
|
required
|
|
/>
|
|
<div className="flex h-9 items-center rounded-lg border bg-muted/30 px-3 text-sm">Consulting</div>
|
|
</div> */}
|
|
<Input placeholder="Titel" value={editForm.title} onChange={(event) => setEditForm({ ...editForm, title: event.currentTarget.value })} />
|
|
<div className="grid gap-3 md:grid-cols-2">
|
|
<Input type="date" value={editForm.startsOn} onChange={(event) => setEditForm({ ...editForm, startsOn: event.currentTarget.value })} />
|
|
<Input type="date" value={editForm.endsOn} onChange={(event) => setEditForm({ ...editForm, endsOn: event.currentTarget.value })} />
|
|
</div>
|
|
<ProjectTeamspaceEntriesFields
|
|
entries={editForm.teamspaceEntries}
|
|
onChange={(teamspaceEntries) => setEditForm({ ...editForm, teamspaceEntries })}
|
|
/>
|
|
<Textarea className="min-h-24" value={editForm.description} onChange={(event) => setEditForm({ ...editForm, description: event.currentTarget.value })} />
|
|
<div className="flex flex-wrap justify-end gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
disabled={savingProjectStatusId === project.id}
|
|
onClick={() => void setProjectClosed(project, !project.closed_at)}
|
|
>
|
|
{project.closed_at ? <RotateCcw className="size-4" /> : <CheckCircle2 className="size-4" />}
|
|
{project.closed_at ? "Wieder öffnen" : "Projekt schließen"}
|
|
</Button>
|
|
<Button type="button" variant="ghost" onClick={() => setEditingProjectId(null)}>
|
|
Abbrechen
|
|
</Button>
|
|
<Button type="button" disabled={savingProjectId === project.id} onClick={() => void saveProject(project)}>
|
|
<Save className="size-4" />
|
|
Speichern
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
})}
|
|
</Card>
|
|
|
|
{!loading && filteredProjects.length === 0 ? (
|
|
<p className="rounded-md border bg-muted/20 p-6 text-center text-sm text-muted-foreground">
|
|
{projects.length === 0 ? "Noch keine Projekte angelegt." : "Keine Projekte für die aktuellen Filter gefunden."}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|