Update
This commit is contained in:
1 parent
07ef8ef025
commit
87d6c9b3ce
10 files changed
+2921
-108
No files matched your search
+1633
-48
File diff suppressed because it is too large.
Load diff
@@ -469,6 +469,7 @@ export async function migrate() {
|
|||||||
hour_budget_minutes INTEGER,
|
hour_budget_minutes INTEGER,
|
||||||
manual_consumption_minutes INTEGER NOT NULL DEFAULT 0,
|
manual_consumption_minutes INTEGER NOT NULL DEFAULT 0,
|
||||||
closed_at TIMESTAMPTZ,
|
closed_at TIMESTAMPTZ,
|
||||||
|
close_review_required BOOLEAN NOT NULL DEFAULT false,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
UNIQUE (user_id, ticket_id),
|
UNIQUE (user_id, ticket_id),
|
||||||
@@ -479,6 +480,7 @@ export async function migrate() {
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
await query("ALTER TABLE projects ADD COLUMN IF NOT EXISTS closed_at TIMESTAMPTZ;");
|
await query("ALTER TABLE projects ADD COLUMN IF NOT EXISTS closed_at TIMESTAMPTZ;");
|
||||||
|
await query("ALTER TABLE projects ADD COLUMN IF NOT EXISTS close_review_required BOOLEAN NOT NULL DEFAULT false;");
|
||||||
await query("ALTER TABLE projects ADD COLUMN IF NOT EXISTS manual_consumption_minutes INTEGER NOT NULL DEFAULT 0;");
|
await query("ALTER TABLE projects ADD COLUMN IF NOT EXISTS manual_consumption_minutes INTEGER NOT NULL DEFAULT 0;");
|
||||||
await query(`
|
await query(`
|
||||||
DO $$
|
DO $$
|
||||||
@@ -512,6 +514,15 @@ export async function migrate() {
|
|||||||
|
|
||||||
await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_project_teamspace_entries_default ON project_teamspace_entries(project_id) WHERE is_default;");
|
await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_project_teamspace_entries_default ON project_teamspace_entries(project_id) WHERE is_default;");
|
||||||
await query("CREATE INDEX IF NOT EXISTS idx_project_teamspace_entries_project_order ON project_teamspace_entries(project_id, display_order, id);");
|
await query("CREATE INDEX IF NOT EXISTS idx_project_teamspace_entries_project_order ON project_teamspace_entries(project_id, display_order, id);");
|
||||||
|
await query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS hidden_user_projects (
|
||||||
|
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
hidden_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (user_id, project_id)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
await query("CREATE INDEX IF NOT EXISTS idx_hidden_user_projects_project ON hidden_user_projects(project_id, user_id);");
|
||||||
await query(`
|
await query(`
|
||||||
DO $$
|
DO $$
|
||||||
BEGIN
|
BEGIN
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Command,
|
Command,
|
||||||
ChartNoAxesCombined,
|
ChartNoAxesCombined,
|
||||||
CircleHelp,
|
CircleHelp,
|
||||||
|
DatabaseZap,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
FileText,
|
FileText,
|
||||||
FolderKanban,
|
FolderKanban,
|
||||||
@@ -84,6 +85,7 @@ import { SetupPage } from "./views/SetupPage";
|
|||||||
import { TicketDetailPage } from "./views/TicketDetailPage";
|
import { TicketDetailPage } from "./views/TicketDetailPage";
|
||||||
import { TimerPage } from "./views/TimerPage";
|
import { TimerPage } from "./views/TimerPage";
|
||||||
import { TodosPage } from "./views/TodosPage";
|
import { TodosPage } from "./views/TodosPage";
|
||||||
|
import { ZammadSyncPage } from "./views/ZammadSyncPage";
|
||||||
import type { AuthUser, PeriodType, TicketDetailPeriodType, TicketSearchResult, TicketTodo } from "./types";
|
import type { AuthUser, PeriodType, TicketDetailPeriodType, TicketSearchResult, TicketTodo } from "./types";
|
||||||
import type { Dispatch, DragEvent, SetStateAction } from "react";
|
import type { Dispatch, DragEvent, SetStateAction } from "react";
|
||||||
|
|
||||||
@@ -217,6 +219,10 @@ function routeFromPath(pathWithSearch: string) {
|
|||||||
return { page: "recurring" as const };
|
return { page: "recurring" as const };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pathname.startsWith("/zammad-sync")) {
|
||||||
|
return { page: "zammad-sync" as const };
|
||||||
|
}
|
||||||
|
|
||||||
if (pathname.startsWith("/admin/users")) {
|
if (pathname.startsWith("/admin/users")) {
|
||||||
return { page: "admin-users" as const };
|
return { page: "admin-users" as const };
|
||||||
}
|
}
|
||||||
@@ -1207,6 +1213,7 @@ export function App() {
|
|||||||
{ type: "item" as const, href: "/statistics", label: "Statistiken", icon: ChartNoAxesCombined, active: path.startsWith("/statistics") },
|
{ type: "item" as const, href: "/statistics", label: "Statistiken", icon: ChartNoAxesCombined, active: path.startsWith("/statistics") },
|
||||||
{ type: "item" as const, href: "/recurring", label: "Fixe Abrechnung", icon: Repeat, active: path.startsWith("/recurring") },
|
{ type: "item" as const, href: "/recurring", label: "Fixe Abrechnung", icon: Repeat, active: path.startsWith("/recurring") },
|
||||||
{ type: "separator" as const, key: "before-profile" },
|
{ type: "separator" as const, key: "before-profile" },
|
||||||
|
{ type: "item" as const, href: "/zammad-sync", label: "Zammad-Sync", icon: DatabaseZap, active: path.startsWith("/zammad-sync") },
|
||||||
{ type: "item" as const, href: "/profile", label: "Profil", icon: UserCog, active: path.startsWith("/profile") },
|
{ type: "item" as const, href: "/profile", label: "Profil", icon: UserCog, active: path.startsWith("/profile") },
|
||||||
{ type: "separator" as const, key: "before-faq" },
|
{ type: "separator" as const, key: "before-faq" },
|
||||||
{ type: "item" as const, href: "/faq", label: "FAQ", icon: CircleHelp, active: path.startsWith("/faq") }
|
{ type: "item" as const, href: "/faq", label: "FAQ", icon: CircleHelp, active: path.startsWith("/faq") }
|
||||||
@@ -1471,6 +1478,7 @@ export function App() {
|
|||||||
{!isAdmin && route.page === "projects" ? <ProjectsPage onNavigate={navigate} onStartTimer={startQuickTimer} /> : null}
|
{!isAdmin && route.page === "projects" ? <ProjectsPage onNavigate={navigate} onStartTimer={startQuickTimer} /> : null}
|
||||||
{!isAdmin && route.page === "todos" ? <TodosPage onNavigate={navigate} /> : null}
|
{!isAdmin && route.page === "todos" ? <TodosPage onNavigate={navigate} /> : null}
|
||||||
{!isAdmin && route.page === "recurring" ? <RecurringBillingsPage /> : null}
|
{!isAdmin && route.page === "recurring" ? <RecurringBillingsPage /> : null}
|
||||||
|
{!isAdmin && route.page === "zammad-sync" ? <ZammadSyncPage currentUser={currentUser} /> : null}
|
||||||
{route.page === "faq" ? <FaqPage /> : null}
|
{route.page === "faq" ? <FaqPage /> : null}
|
||||||
{route.page === "profile" ? <ProfilePage currentUser={currentUser} onUserUpdated={setCurrentUser} /> : null}
|
{route.page === "profile" ? <ProfilePage currentUser={currentUser} onUserUpdated={setCurrentUser} /> : null}
|
||||||
{route.page === "admin-users" && isAdmin ? <AdminUsersPage currentUser={currentUser} /> : null}
|
{route.page === "admin-users" && isAdmin ? <AdminUsersPage currentUser={currentUser} /> : null}
|
||||||
|
|||||||
+185
-7
@@ -42,6 +42,54 @@ type DeleteSessionResult = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ZammadSyncExistingFields = {
|
||||||
|
title: boolean;
|
||||||
|
organization: boolean;
|
||||||
|
type: boolean;
|
||||||
|
projectName: boolean;
|
||||||
|
budget: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ZammadSyncChange = {
|
||||||
|
field: string;
|
||||||
|
from: unknown;
|
||||||
|
to: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ZammadSyncItem = {
|
||||||
|
ticketNumber: string;
|
||||||
|
action: "update" | "create" | "skip" | "error";
|
||||||
|
reason: string | null;
|
||||||
|
local: Record<string, unknown> | null;
|
||||||
|
zammad: Record<string, unknown> | null;
|
||||||
|
changes: ZammadSyncChange[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ZammadSyncResult = {
|
||||||
|
mode: "preview" | "applied";
|
||||||
|
checked: number;
|
||||||
|
updated: number;
|
||||||
|
created: number;
|
||||||
|
skipped: number;
|
||||||
|
missingInZammad: number;
|
||||||
|
organizationMissing: number;
|
||||||
|
markedForCloseReview: number;
|
||||||
|
convertedToConsulting: number;
|
||||||
|
convertedToSupport: number;
|
||||||
|
errors: Array<{ ticketNumber: string; error: string }>;
|
||||||
|
items: ZammadSyncItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ZammadSyncJob = {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
status: "running" | "complete" | "error";
|
||||||
|
result: ZammadSyncResult | null;
|
||||||
|
error: string | null;
|
||||||
|
startedAt: string;
|
||||||
|
finishedAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type RestoreValidationResult = {
|
export type RestoreValidationResult = {
|
||||||
valid: boolean;
|
valid: boolean;
|
||||||
checkedTables: number;
|
checkedTables: number;
|
||||||
@@ -58,15 +106,46 @@ export class ApiError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
|
type ApiRequestInit = RequestInit & {
|
||||||
const response = await fetch(url, {
|
timeoutMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function request<T>(url: string, options: ApiRequestInit = {}): Promise<T> {
|
||||||
|
const { timeoutMs, signal, ...fetchOptions } = options;
|
||||||
|
const controller = timeoutMs ? new AbortController() : null;
|
||||||
|
const timeout = controller
|
||||||
|
? window.setTimeout(() => {
|
||||||
|
controller.abort();
|
||||||
|
}, timeoutMs)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (signal && controller) {
|
||||||
|
signal.addEventListener("abort", () => controller.abort(), { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
|
||||||
|
try {
|
||||||
|
response = await fetch(url, {
|
||||||
|
signal: controller?.signal ?? signal,
|
||||||
|
...fetchOptions,
|
||||||
credentials: "same-origin",
|
credentials: "same-origin",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
...options.headers
|
...fetchOptions.headers
|
||||||
},
|
}
|
||||||
...options
|
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DOMException && error.name === "AbortError") {
|
||||||
|
throw new ApiError("Anfrage hat zu lange gedauert", 408);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
if (timeout) {
|
||||||
|
window.clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const body = await response.json().catch(() => ({}));
|
const body = await response.json().catch(() => ({}));
|
||||||
@@ -266,6 +345,89 @@ export function syncZammadOrganizations(payload: { baseUrl?: string; apiKey?: st
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function wait(milliseconds: number) {
|
||||||
|
return new Promise((resolve) => window.setTimeout(resolve, milliseconds));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForZammadSyncJob(jobId: string) {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const timeoutMs = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
if (Date.now() - startedAt > timeoutMs) {
|
||||||
|
throw new ApiError("Zammad-Sync läuft länger als erwartet. Bitte Vorschau neu starten.", 408);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { job } = await request<{ job: ZammadSyncJob }>(`/api/zammad-sync/jobs/${encodeURIComponent(jobId)}`, { timeoutMs: 20_000 });
|
||||||
|
|
||||||
|
if (job.status === "complete" && job.result) {
|
||||||
|
return job.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (job.status === "error") {
|
||||||
|
throw new ApiError(job.error ?? "Zammad-Sync fehlgeschlagen", 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
await wait(1200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runZammadSyncJob(url: string, body: Record<string, unknown>) {
|
||||||
|
const { job } = await request<{ job: ZammadSyncJob }>(url, {
|
||||||
|
method: "POST",
|
||||||
|
timeoutMs: 20_000,
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
const result = await waitForZammadSyncJob(job.id);
|
||||||
|
|
||||||
|
return { jobId: job.id, result };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncExistingZammadTickets(fields: ZammadSyncExistingFields, options: { dryRun?: boolean; ticketNumbers?: string[] } = {}) {
|
||||||
|
const { result } = await runZammadSyncJob("/api/zammad-sync/jobs/existing", {
|
||||||
|
fields,
|
||||||
|
dryRun: options.dryRun ?? true,
|
||||||
|
ticketNumbers: options.ticketNumbers
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importOwnZammadConsultingTickets(options: { dryRun?: boolean; ticketNumbers?: string[] } = {}) {
|
||||||
|
const { result } = await runZammadSyncJob("/api/zammad-sync/jobs/consulting-import", {
|
||||||
|
dryRun: options.dryRun ?? true,
|
||||||
|
ticketNumbers: options.ticketNumbers
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function previewExistingZammadTickets(fields: ZammadSyncExistingFields) {
|
||||||
|
return runZammadSyncJob("/api/zammad-sync/jobs/existing", {
|
||||||
|
fields,
|
||||||
|
dryRun: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyExistingZammadTicketsFromPreview(previewJobId: string, fields: ZammadSyncExistingFields, ticketNumbers: string[]) {
|
||||||
|
return runZammadSyncJob(`/api/zammad-sync/jobs/${encodeURIComponent(previewJobId)}/apply-existing`, {
|
||||||
|
fields,
|
||||||
|
ticketNumbers
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function previewOwnZammadConsultingTickets() {
|
||||||
|
return runZammadSyncJob("/api/zammad-sync/jobs/consulting-import", {
|
||||||
|
dryRun: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyOwnZammadConsultingTicketsFromPreview(previewJobId: string, ticketNumbers: string[]) {
|
||||||
|
return runZammadSyncJob(`/api/zammad-sync/jobs/${encodeURIComponent(previewJobId)}/apply-consulting-import`, {
|
||||||
|
ticketNumbers
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function getRecurringBillings() {
|
export function getRecurringBillings() {
|
||||||
return request<{ recurringBillings: RecurringBilling[] }>("/api/recurring-billings");
|
return request<{ recurringBillings: RecurringBilling[] }>("/api/recurring-billings");
|
||||||
}
|
}
|
||||||
@@ -324,14 +486,18 @@ export function deleteRecurringBilling(billingId: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getProjects(includeClosed = false) {
|
export function getProjects(includeClosed = false, hidden = false) {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
if (includeClosed) {
|
if (includeClosed) {
|
||||||
params.set("includeClosed", "true");
|
params.set("includeClosed", "true");
|
||||||
}
|
}
|
||||||
|
|
||||||
return request<{ projects: ProjectSummary[] }>(`/api/projects${params.size ? `?${params}` : ""}`);
|
if (hidden) {
|
||||||
|
params.set("hidden", "true");
|
||||||
|
}
|
||||||
|
|
||||||
|
return request<{ projects: ProjectSummary[]; hiddenCount: number; totalCount: number }>(`/api/projects${params.size ? `?${params}` : ""}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getProject(projectId: string) {
|
export function getProject(projectId: string) {
|
||||||
@@ -405,6 +571,18 @@ export function deleteProject(projectId: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function hideProject(projectId: string) {
|
||||||
|
return request<{ hidden: { id: string } }>(`/api/projects/${projectId}/hide`, {
|
||||||
|
method: "POST"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unhideProject(projectId: string) {
|
||||||
|
return request<{ shown: { id: string } }>(`/api/projects/${projectId}/hide`, {
|
||||||
|
method: "DELETE"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function getTodos(status?: TodoStatus | "all") {
|
export function getTodos(status?: TodoStatus | "all") {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
|||||||
@@ -159,6 +159,8 @@ export type ProjectSummary = {
|
|||||||
teamspace_entry_count?: number;
|
teamspace_entry_count?: number;
|
||||||
teamspace_entries?: ProjectTeamspaceEntry[];
|
teamspace_entries?: ProjectTeamspaceEntry[];
|
||||||
unassigned_teamspace_session_count?: number;
|
unassigned_teamspace_session_count?: number;
|
||||||
|
close_review_required?: boolean;
|
||||||
|
hidden_for_user?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ProjectTeamspaceEntry = {
|
export type ProjectTeamspaceEntry = {
|
||||||
|
|||||||
@@ -484,7 +484,7 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2 md:grid-cols-2 xl:grid-cols-[minmax(180px,1fr)_150px_190px_140px_140px_140px_120px]">
|
<div className="grid gap-2 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-[minmax(180px,1fr)_150px_190px_140px_140px_140px_140px]">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-xs font-medium text-muted-foreground" htmlFor="ticket-filter-query">Suche</label>
|
<label className="text-xs font-medium text-muted-foreground" htmlFor="ticket-filter-query">Suche</label>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ArrowDown, ArrowUp, CheckCircle2, MoreHorizontal, Pencil, Play, Plus, RotateCcw, Save, Search, SlidersHorizontal, Star, Trash2 } from "lucide-react";
|
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 { FormEvent, useEffect, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ import { Textarea } from "@/components/ui/textarea";
|
|||||||
import { HelpLink } from "@/components/HelpLink";
|
import { HelpLink } from "@/components/HelpLink";
|
||||||
import { OrganizationSelect } from "@/components/OrganizationSelect";
|
import { OrganizationSelect } from "@/components/OrganizationSelect";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { addFavoriteTicket, createProject, getProjects, lookupTicket, updateProject, updateProjectStatus } from "../api";
|
import { addFavoriteTicket, createProject, getProjects, hideProject, lookupTicket, unhideProject, updateProject, updateProjectStatus } from "../api";
|
||||||
import { currentMonth, formatMinutes } from "../format";
|
import { currentMonth, formatMinutes } from "../format";
|
||||||
import { projectHasRecurringBilling } from "../recurring-billings";
|
import { projectHasRecurringBilling } from "../recurring-billings";
|
||||||
import { ticketPattern } from "../timers";
|
import { ticketPattern } from "../timers";
|
||||||
@@ -43,7 +43,7 @@ type ProjectTeamspaceEntryForm = {
|
|||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ProjectStatusFilter = "open" | "all" | "closed";
|
type ProjectStatusFilter = "open" | "all" | "closed" | "hidden";
|
||||||
type ProjectBudgetFilter = "all" | "with-budget" | "without-budget" | "remaining" | "over-budget";
|
type ProjectBudgetFilter = "all" | "with-budget" | "without-budget" | "remaining" | "over-budget";
|
||||||
type ProjectTodoFilter = "all" | "open" | "overdue" | "none-open";
|
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" | "consumption" | "consumptionPercent" | "remaining" | "budget" | "todos";
|
||||||
@@ -74,7 +74,8 @@ const defaultProjectViewSettings: ProjectViewSettings = {
|
|||||||
const projectStatusOptions: Array<{ value: ProjectStatusFilter; label: string }> = [
|
const projectStatusOptions: Array<{ value: ProjectStatusFilter; label: string }> = [
|
||||||
{ value: "open", label: "Offen" },
|
{ value: "open", label: "Offen" },
|
||||||
{ value: "all", label: "Alle" },
|
{ value: "all", label: "Alle" },
|
||||||
{ value: "closed", label: "Geschlossen" }
|
{ value: "closed", label: "Geschlossen" },
|
||||||
|
{ value: "hidden", label: "Ausgeblendet" }
|
||||||
];
|
];
|
||||||
|
|
||||||
const projectBudgetOptions: Array<{ value: ProjectBudgetFilter; label: string }> = [
|
const projectBudgetOptions: Array<{ value: ProjectBudgetFilter; label: string }> = [
|
||||||
@@ -94,6 +95,19 @@ const projectTodoOptions: Array<{ value: ProjectTodoFilter; label: string }> = [
|
|||||||
|
|
||||||
const projectSortValues: ProjectSortBy[] = ["updated", "ticket", "title", "organization", "status", "consumption", "consumptionPercent", "remaining", "budget", "todos"];
|
const projectSortValues: ProjectSortBy[] = ["updated", "ticket", "title", "organization", "status", "consumption", "consumptionPercent", "remaining", "budget", "todos"];
|
||||||
|
|
||||||
|
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: "consumption", label: "Verbrauch" },
|
||||||
|
{ value: "consumptionPercent", label: "Verbrauch %" },
|
||||||
|
{ value: "remaining", label: "Reststunden" },
|
||||||
|
{ value: "budget", label: "Kontingent" },
|
||||||
|
{ value: "todos", label: "ToDos" }
|
||||||
|
];
|
||||||
|
|
||||||
function projectEntryId() {
|
function projectEntryId() {
|
||||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||||
return crypto.randomUUID();
|
return crypto.randomUUID();
|
||||||
@@ -582,6 +596,8 @@ function ProjectFormFields({ form, setForm, organizationLocked, lookupStatus, za
|
|||||||
|
|
||||||
export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
||||||
const [projects, setProjects] = useState<ProjectSummary[]>([]);
|
const [projects, setProjects] = useState<ProjectSummary[]>([]);
|
||||||
|
const [hiddenProjectCount, setHiddenProjectCount] = useState(0);
|
||||||
|
const [totalProjectCount, setTotalProjectCount] = useState(0);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [form, setForm] = useState<ProjectForm>(() => emptyProjectForm());
|
const [form, setForm] = useState<ProjectForm>(() => emptyProjectForm());
|
||||||
const [lookupStatus, setLookupStatus] = useState<"idle" | "loading" | "found" | "not-found">("idle");
|
const [lookupStatus, setLookupStatus] = useState<"idle" | "loading" | "found" | "not-found">("idle");
|
||||||
@@ -599,8 +615,10 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
|||||||
async function load() {
|
async function load() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const result = await getProjects(viewSettings.status !== "open");
|
const result = await getProjects(viewSettings.status !== "open", viewSettings.status === "hidden");
|
||||||
setProjects(result.projects);
|
setProjects(result.projects);
|
||||||
|
setHiddenProjectCount(result.hiddenCount ?? 0);
|
||||||
|
setTotalProjectCount(result.totalCount ?? result.projects.length);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error("Projekte konnten nicht geladen werden", {
|
toast.error("Projekte konnten nicht geladen werden", {
|
||||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||||
@@ -686,6 +704,10 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
|||||||
const filteredProjects = useMemo(() => {
|
const filteredProjects = useMemo(() => {
|
||||||
return projects
|
return projects
|
||||||
.filter((project) => {
|
.filter((project) => {
|
||||||
|
if (viewSettings.status === "hidden" && !project.hidden_for_user) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (viewSettings.status === "open" && project.closed_at) {
|
if (viewSettings.status === "open" && project.closed_at) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -975,6 +997,34 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||||||
@@ -1005,31 +1055,6 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
|||||||
<span className="font-semibold">{openTodos}</span>
|
<span className="font-semibold">{openTodos}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
|
||||||
<HelpLink anchor="projekte" label="Hilfe zu Projekten" className="shrink-0" />
|
<HelpLink anchor="projekte" label="Hilfe zu Projekten" className="shrink-0" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1043,7 +1068,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
|||||||
Filter und Sortierung
|
Filter und Sortierung
|
||||||
{activeFilterCount > 0 ? <Badge variant="secondary">{activeFilterCount}</Badge> : null}
|
{activeFilterCount > 0 ? <Badge variant="secondary">{activeFilterCount}</Badge> : null}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>{filteredProjects.length} von {projects.length} Projekt(en) sichtbar.</CardDescription>
|
<CardDescription>{filteredProjects.length} von {totalProjectCount} Projekt(en) sichtbar.</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Button type="button" size="sm" variant="ghost" onClick={() => setViewSettings(defaultProjectViewSettings)}>
|
<Button type="button" size="sm" variant="ghost" onClick={() => setViewSettings(defaultProjectViewSettings)}>
|
||||||
<RotateCcw className="size-4" />
|
<RotateCcw className="size-4" />
|
||||||
@@ -1051,7 +1076,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid gap-3 px-4 pb-4 md:grid-cols-2 xl:grid-cols-[minmax(220px,1.4fr)_150px_minmax(180px,1fr)_170px_170px_130px_150px]">
|
<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]">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-xs font-medium text-muted-foreground" htmlFor="project-search">
|
<label className="text-xs font-medium text-muted-foreground" htmlFor="project-search">
|
||||||
Suche
|
Suche
|
||||||
@@ -1122,23 +1147,16 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-xs font-medium text-muted-foreground">Sortierung</label>
|
<label className="text-xs font-medium text-muted-foreground">Sortierung</label>
|
||||||
<Button
|
<Select value={viewSettings.sortBy} onValueChange={(value) => setViewSettings((current) => ({ ...current, sortBy: value as ProjectSortBy }))}>
|
||||||
type="button"
|
<SelectTrigger size="sm" className="w-full">
|
||||||
size="sm"
|
<SelectValue />
|
||||||
variant="secondary"
|
</SelectTrigger>
|
||||||
className="h-8 w-full justify-start"
|
<SelectContent>
|
||||||
disabled={viewSettings.sortBy === defaultProjectViewSettings.sortBy && viewSettings.sortDirection === defaultProjectViewSettings.sortDirection}
|
{projectSortOptions.map((option) => (
|
||||||
onClick={() =>
|
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
|
||||||
setViewSettings((current) => ({
|
))}
|
||||||
...current,
|
</SelectContent>
|
||||||
sortBy: defaultProjectViewSettings.sortBy,
|
</Select>
|
||||||
sortDirection: defaultProjectViewSettings.sortDirection
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<RotateCcw className="size-3.5" />
|
|
||||||
Zurücksetzen
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-xs font-medium text-muted-foreground">Richtung</label>
|
<label className="text-xs font-medium text-muted-foreground">Richtung</label>
|
||||||
@@ -1167,8 +1185,57 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
{hiddenProjectCount > 0 && viewSettings.status !== "hidden" ? (
|
||||||
|
<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, status: "hidden" }))}
|
||||||
|
>
|
||||||
|
Anzeigen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<Card className="overflow-hidden">
|
<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(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(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">
|
||||||
{sortHeader("Nummer/Titel", "title")}
|
{sortHeader("Nummer/Titel", "title")}
|
||||||
{sortHeader("Organisation", "organization")}
|
{sortHeader("Organisation", "organization")}
|
||||||
@@ -1207,6 +1274,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
|||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{project.closed_at ? <Badge variant="secondary">geschlossen</Badge> : <Badge variant="success">offen</Badge>}
|
{project.closed_at ? <Badge variant="secondary">geschlossen</Badge> : <Badge variant="success">offen</Badge>}
|
||||||
{!canEditProject ? <Badge variant="outline">geteilt</Badge> : null}
|
{!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}
|
{(project.unassigned_teamspace_session_count ?? 0) > 0 ? <Badge variant="warning">{project.unassigned_teamspace_session_count} ohne TS</Badge> : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@@ -1244,6 +1312,17 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
|||||||
<Star className="size-4" />
|
<Star className="size-4" />
|
||||||
Als Favorit speichern
|
Als Favorit speichern
|
||||||
</DropdownMenuItem>
|
</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()}`)}>
|
<DropdownMenuItem className="hover:cursor-pointer" onSelect={() => onNavigate(`/tickets/${project.ticket_id}?month=${currentMonth()}`)}>
|
||||||
<Search className="size-4" />
|
<Search className="size-4" />
|
||||||
Projekt öffnen
|
Projekt öffnen
|
||||||
@@ -1286,6 +1365,11 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
|
|||||||
{project.unassigned_teamspace_session_count} Session(s) haben keinen Teamspace-Eintrag.
|
{project.unassigned_teamspace_session_count} Session(s) haben keinen Teamspace-Eintrag.
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : 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}
|
||||||
{hasMultipleTeamspaceEntries ? (
|
{hasMultipleTeamspaceEntries ? (
|
||||||
<div className="grid gap-1.5 md:grid-cols-2">
|
<div className="grid gap-1.5 md:grid-cols-2">
|
||||||
{teamspaceEntries.map((entry) => {
|
{teamspaceEntries.map((entry) => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ArrowLeft, CheckCircle2, ChevronDown, ChevronUp, ClipboardList, FolderKanban, Pencil, Plus, RotateCcw, Save, Star, Trash2 } from "lucide-react";
|
import { ArrowLeft, CheckCircle2, ChevronDown, ChevronUp, ClipboardList, EyeOff, FolderKanban, Pencil, Plus, RotateCcw, Save, Star, Trash2 } from "lucide-react";
|
||||||
import { FormEvent, useEffect, useMemo, useState } from "react";
|
import { FormEvent, useEffect, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Alert } from "@/components/ui/alert";
|
import { Alert } from "@/components/ui/alert";
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
createSession,
|
createSession,
|
||||||
deleteProject,
|
deleteProject,
|
||||||
deleteSession,
|
deleteSession,
|
||||||
|
hideProject,
|
||||||
addFavoriteTicket,
|
addFavoriteTicket,
|
||||||
getDashboardPreference,
|
getDashboardPreference,
|
||||||
getTicketDetails,
|
getTicketDetails,
|
||||||
@@ -1261,6 +1262,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
|||||||
Projekt
|
Projekt
|
||||||
{project.closed_at ? <Badge variant="secondary">geschlossen</Badge> : <Badge variant="success">offen</Badge>}
|
{project.closed_at ? <Badge variant="secondary">geschlossen</Badge> : <Badge variant="success">offen</Badge>}
|
||||||
{!canEditProject ? <Badge variant="outline">geteilt</Badge> : null}
|
{!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}
|
{(project.unassigned_teamspace_session_count ?? 0) > 0 ? <Badge variant="warning">{project.unassigned_teamspace_session_count} ohne TS</Badge> : null}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
@@ -1299,6 +1301,14 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
|
|||||||
</div>
|
</div>
|
||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
{project.close_review_required ? (
|
||||||
|
<Alert variant="warning">
|
||||||
|
<div className="font-medium">Zeiten prüfen</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Zammad meldet dieses Consulting-Ticket als geschlossen. Bitte Zeiten in Teamspace und TicketTracker prüfen und das Projekt danach schließen.
|
||||||
|
</div>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
<div className="flex flex-wrap gap-2 text-sm">
|
<div className="flex flex-wrap gap-2 text-sm">
|
||||||
<Badge variant="secondary">Zeitraum {project.starts_on || "offen"} - {project.ends_on || "offen"}</Badge>
|
<Badge variant="secondary">Zeitraum {project.starts_on || "offen"} - {project.ends_on || "offen"}</Badge>
|
||||||
<Badge variant="secondary">Kontingent {projectBudgetMinutes ? formatMinutes(projectBudgetMinutes) : "offen"}</Badge>
|
<Badge variant="secondary">Kontingent {projectBudgetMinutes ? formatMinutes(projectBudgetMinutes) : "offen"}</Badge>
|
||||||
|
|||||||
@@ -784,7 +784,7 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
<HelpLink anchor="todos" label="Hilfe zu ToDos" />
|
<HelpLink anchor="todos" label="Hilfe zu ToDos" />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-3 pt-2 text-sm lg:grid-cols-[minmax(180px,1fr)_150px_190px_auto] lg:items-end">
|
<div className="grid gap-3 pt-2 text-sm md:grid-cols-2 xl:grid-cols-[minmax(180px,1fr)_150px_190px_140px] xl:items-end">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-xs font-medium text-muted-foreground" htmlFor="todo-ticket-search">
|
<label className="text-xs font-medium text-muted-foreground" htmlFor="todo-ticket-search">
|
||||||
Ticket
|
Ticket
|
||||||
|
|||||||
@@ -0,0 +1,935 @@
|
|||||||
|
import { useMemo, useSyncExternalStore } from "react";
|
||||||
|
import { AlertCircle, CheckCircle2, DatabaseZap, DownloadCloud, RefreshCw } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
|
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
applyExistingZammadTicketsFromPreview,
|
||||||
|
applyOwnZammadConsultingTicketsFromPreview,
|
||||||
|
previewExistingZammadTickets,
|
||||||
|
previewOwnZammadConsultingTickets,
|
||||||
|
type ZammadSyncExistingFields,
|
||||||
|
type ZammadSyncChange,
|
||||||
|
type ZammadSyncItem,
|
||||||
|
type ZammadSyncResult
|
||||||
|
} from "../api";
|
||||||
|
import type { AuthUser } from "../types";
|
||||||
|
|
||||||
|
type ZammadSyncPageProps = {
|
||||||
|
currentUser: AuthUser;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SelectionMap = Record<string, boolean>;
|
||||||
|
|
||||||
|
const defaultFields: ZammadSyncExistingFields = {
|
||||||
|
title: true,
|
||||||
|
organization: true,
|
||||||
|
type: true,
|
||||||
|
projectName: true,
|
||||||
|
budget: true
|
||||||
|
};
|
||||||
|
|
||||||
|
const fieldOptions: Array<{ key: keyof ZammadSyncExistingFields; label: string; description: string }> = [
|
||||||
|
{ key: "title", label: "Titel", description: "Ticket-Titel aus Zammad übernehmen." },
|
||||||
|
{ key: "organization", label: "Organisation", description: "Lokale Organisation anhand der Zammad-ID setzen." },
|
||||||
|
{ key: "type", label: "Typ", description: "Support/Consulting übernehmen, Projekte anlegen oder entfernen." },
|
||||||
|
{ key: "projectName", label: "Projektbezeichnung", description: "Teamspace-Projekt bei Consulting überschreiben." },
|
||||||
|
{ key: "budget", label: "Budget", description: "Teamspace-Stundenkontingent überschreiben." }
|
||||||
|
];
|
||||||
|
|
||||||
|
type ZammadSyncPageState = {
|
||||||
|
fields: ZammadSyncExistingFields;
|
||||||
|
existingResult: ZammadSyncResult | null;
|
||||||
|
existingPreviewJobId: string | null;
|
||||||
|
importResult: ZammadSyncResult | null;
|
||||||
|
importPreviewJobId: string | null;
|
||||||
|
existingSelection: SelectionMap;
|
||||||
|
importSelection: SelectionMap;
|
||||||
|
syncingExisting: boolean;
|
||||||
|
importingConsulting: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
let zammadSyncPageState: ZammadSyncPageState = {
|
||||||
|
fields: defaultFields,
|
||||||
|
existingResult: null,
|
||||||
|
existingPreviewJobId: null,
|
||||||
|
importResult: null,
|
||||||
|
importPreviewJobId: null,
|
||||||
|
existingSelection: {},
|
||||||
|
importSelection: {},
|
||||||
|
syncingExisting: false,
|
||||||
|
importingConsulting: false
|
||||||
|
};
|
||||||
|
|
||||||
|
const zammadSyncPageListeners = new Set<() => void>();
|
||||||
|
|
||||||
|
function getZammadSyncPageState() {
|
||||||
|
return zammadSyncPageState;
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribeZammadSyncPageState(listener: () => void) {
|
||||||
|
zammadSyncPageListeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
zammadSyncPageListeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setZammadSyncPageState(update: Partial<ZammadSyncPageState> | ((current: ZammadSyncPageState) => Partial<ZammadSyncPageState>)) {
|
||||||
|
zammadSyncPageState = {
|
||||||
|
...zammadSyncPageState,
|
||||||
|
...(typeof update === "function" ? update(zammadSyncPageState) : update)
|
||||||
|
};
|
||||||
|
zammadSyncPageListeners.forEach((listener) => listener());
|
||||||
|
}
|
||||||
|
|
||||||
|
function useZammadSyncPageState() {
|
||||||
|
return useSyncExternalStore(subscribeZammadSyncPageState, getZammadSyncPageState, getZammadSyncPageState);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectableItems(result: ZammadSyncResult) {
|
||||||
|
return result.items.filter((item) => item.action === "update" || item.action === "create");
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemIsSelected(item: ZammadSyncItem, selection: SelectionMap) {
|
||||||
|
return (item.action === "update" || item.action === "create") && selection[item.ticketNumber] !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectionFromResult(result: ZammadSyncResult): SelectionMap {
|
||||||
|
return Object.fromEntries(selectableItems(result).map((item) => [item.ticketNumber, true]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedTicketNumbers(result: ZammadSyncResult | null, selection: SelectionMap) {
|
||||||
|
if (!result) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return selectableItems(result)
|
||||||
|
.filter((item) => itemIsSelected(item, selection))
|
||||||
|
.map((item) => item.ticketNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMinutes(value: unknown) {
|
||||||
|
if (typeof value !== "number") {
|
||||||
|
return formatValue(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === 0) {
|
||||||
|
return "0 h";
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${new Intl.NumberFormat("de-DE", { maximumFractionDigits: value < 600 ? 1 : 0 }).format(value / 60)} h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function numericValue(value: unknown) {
|
||||||
|
const number = Number(value);
|
||||||
|
return Number.isInteger(number) ? number : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatWorkType(value: unknown) {
|
||||||
|
if (value === "consulting") {
|
||||||
|
return "Consulting";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === "support") {
|
||||||
|
return "Support";
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatValue(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatZammadGroup(value: unknown) {
|
||||||
|
const groupId = numericValue(value);
|
||||||
|
|
||||||
|
if (groupId === 2) {
|
||||||
|
return "2 (Consulting)";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (groupId === 3) {
|
||||||
|
return "3 (Support)";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (groupId === 4) {
|
||||||
|
return "4 (Support)";
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatValue(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatZammadPriority(value: unknown) {
|
||||||
|
const priorityId = numericValue(value);
|
||||||
|
|
||||||
|
if (priorityId === 1) {
|
||||||
|
return "1 (Niedrig)";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (priorityId === 2) {
|
||||||
|
return "2 (Normal)";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (priorityId === 3) {
|
||||||
|
return "3 (Hoch)";
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatValue(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatZammadState(value: unknown) {
|
||||||
|
const stateId = numericValue(value);
|
||||||
|
|
||||||
|
if (stateId === 2) {
|
||||||
|
return "2 (offen)";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stateId === 3) {
|
||||||
|
return "3 (warten auf Erinnerung)";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stateId === 4) {
|
||||||
|
return "4 (geschlossen)";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stateId === 7) {
|
||||||
|
return "7 (warten auf schließen)";
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatValue(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatValue(value: unknown): string {
|
||||||
|
if (value === null || value === undefined || value === "") {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "boolean") {
|
||||||
|
return value ? "ja" : "nein";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "object") {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatChangeValue(field: string, value: unknown) {
|
||||||
|
if (field === "Teamspace-Budget") {
|
||||||
|
return formatMinutes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field === "Typ") {
|
||||||
|
return formatWorkType(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field === "Zammad-Gruppe") {
|
||||||
|
return formatZammadGroup(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field === "Zammad-Priorität") {
|
||||||
|
return formatZammadPriority(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field === "Zammad-Status") {
|
||||||
|
return formatZammadState(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatValue(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleTicketTrackerChanges(item: ZammadSyncItem) {
|
||||||
|
return item.changes.filter((change) => !change.field.startsWith("Zammad-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeAllowedByFields(item: ZammadSyncItem, change: ZammadSyncChange, fields: ZammadSyncExistingFields) {
|
||||||
|
const localWorkType = item.local?.workType;
|
||||||
|
const zammadWorkType = item.zammad?.workType;
|
||||||
|
const consultingProjectCanBeTouched = zammadWorkType === "consulting" && (fields.type || localWorkType === "consulting");
|
||||||
|
|
||||||
|
if (change.field === "Titel") {
|
||||||
|
return fields.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (change.field === "Organisation" || change.field === "Kunde") {
|
||||||
|
return fields.organization;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (change.field === "Typ") {
|
||||||
|
return fields.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (change.field === "Projekt" || change.field === "Projekt/Teamspace-Daten") {
|
||||||
|
return fields.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (change.field === "Teamspace-Projekt") {
|
||||||
|
return fields.projectName && consultingProjectCanBeTouched;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (change.field === "Teamspace-Budget") {
|
||||||
|
return fields.budget && consultingProjectCanBeTouched;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (change.field === "TS/TT-Prüfung") {
|
||||||
|
return consultingProjectCanBeTouched;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !change.field.startsWith("Zammad-");
|
||||||
|
}
|
||||||
|
|
||||||
|
function cachedValue(source: Record<string, unknown> | null | undefined, key: string) {
|
||||||
|
return source && Object.prototype.hasOwnProperty.call(source, key) ? source[key] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cachedObject(source: Record<string, unknown> | null | undefined, key: string) {
|
||||||
|
const value = cachedValue(source, key);
|
||||||
|
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function comparablePreviewValue(value: unknown) {
|
||||||
|
if (typeof value === "string" && value.trim().length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cachedValueChanged(left: unknown, right: unknown) {
|
||||||
|
return JSON.stringify(comparablePreviewValue(left)) !== JSON.stringify(comparablePreviewValue(right));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCachedChange(changes: ZammadSyncChange[], field: string, from: unknown, to: unknown) {
|
||||||
|
if (cachedValueChanged(from, to)) {
|
||||||
|
changes.push({ field, from: comparablePreviewValue(from), to: comparablePreviewValue(to) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function changesFromExistingPreviewCache(item: ZammadSyncItem, fields: ZammadSyncExistingFields) {
|
||||||
|
if (!item.zammad) {
|
||||||
|
return item.changes.filter((change) => changeAllowedByFields(item, change, fields));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.reason === "Zammad-Organisation ist lokal nicht synchronisiert." && fields.organization) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const local = item.local;
|
||||||
|
const zammad = item.zammad;
|
||||||
|
const project = cachedObject(local, "project");
|
||||||
|
const localWorkType = cachedValue(local, "workType");
|
||||||
|
const zammadWorkType = cachedValue(zammad, "workType");
|
||||||
|
const zammadStateId = numericValue(cachedValue(zammad, "stateId"));
|
||||||
|
const changes: ZammadSyncChange[] = [];
|
||||||
|
|
||||||
|
if (zammadWorkType === "support" && zammadStateId === 4) {
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fields.title) {
|
||||||
|
addCachedChange(changes, "Titel", cachedValue(local, "title"), cachedValue(zammad, "title"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fields.organization) {
|
||||||
|
addCachedChange(changes, "Organisation", cachedValue(local, "organizationId"), cachedValue(zammad, "organizationId"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fields.type) {
|
||||||
|
addCachedChange(changes, "Typ", localWorkType, zammadWorkType);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fields.type && zammadWorkType === "support" && project) {
|
||||||
|
changes.push({ field: "Projekt/Teamspace-Daten", from: "vorhanden", to: "werden gelöscht" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const consultingProjectCanBeTouched = zammadWorkType === "consulting" && (fields.type || localWorkType === "consulting");
|
||||||
|
|
||||||
|
if (consultingProjectCanBeTouched) {
|
||||||
|
if (fields.type && !project) {
|
||||||
|
changes.push({ field: "Projekt", from: null, to: "wird angelegt" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fields.projectName) {
|
||||||
|
addCachedChange(changes, "Teamspace-Projekt", cachedValue(project, "teamspaceProject"), cachedValue(zammad, "teamspaceProject"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fields.budget) {
|
||||||
|
addCachedChange(changes, "Teamspace-Budget", cachedValue(project, "hourBudgetMinutes"), cachedValue(zammad, "teamspaceBudgetMinutes"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (zammadStateId === 4 && cachedValue(project, "closedAt") === null && cachedValue(project, "closeReviewRequired") !== true) {
|
||||||
|
changes.push({ field: "TS/TT-Prüfung", from: cachedValue(project, "closeReviewRequired") ?? false, to: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebuildZammadSyncResultFromItems(mode: ZammadSyncResult["mode"], items: ZammadSyncItem[], checked = items.length): ZammadSyncResult {
|
||||||
|
const result: ZammadSyncResult = {
|
||||||
|
mode,
|
||||||
|
checked,
|
||||||
|
updated: 0,
|
||||||
|
created: 0,
|
||||||
|
skipped: 0,
|
||||||
|
missingInZammad: 0,
|
||||||
|
organizationMissing: 0,
|
||||||
|
markedForCloseReview: 0,
|
||||||
|
convertedToConsulting: 0,
|
||||||
|
convertedToSupport: 0,
|
||||||
|
errors: [],
|
||||||
|
items
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.action === "create") {
|
||||||
|
result.created += 1;
|
||||||
|
} else if (item.action === "update") {
|
||||||
|
result.updated += 1;
|
||||||
|
} else {
|
||||||
|
result.skipped += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.action === "error") {
|
||||||
|
result.errors.push({ ticketNumber: item.ticketNumber, error: item.reason ?? "Unbekannter Fehler" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!item.zammad && item.reason === "Ticket wurde in Zammad nicht gefunden.") {
|
||||||
|
result.missingInZammad += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.reason === "Zammad-Organisation ist lokal nicht synchronisiert.") {
|
||||||
|
result.organizationMissing += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.changes.some((change) => change.field === "TS/TT-Prüfung")) {
|
||||||
|
result.markedForCloseReview += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeChange = item.changes.find((change) => change.field === "Typ");
|
||||||
|
if (typeChange?.to === "consulting" && item.local?.workType !== "consulting") {
|
||||||
|
result.convertedToConsulting += 1;
|
||||||
|
}
|
||||||
|
if (typeChange?.to === "support" && item.local?.workType === "consulting") {
|
||||||
|
result.convertedToSupport += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFieldsToExistingPreview(result: ZammadSyncResult | null, fields: ZammadSyncExistingFields) {
|
||||||
|
if (!result || result.mode !== "preview") {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = result.items.map((item) => {
|
||||||
|
if (item.action === "create" || item.action === "error") {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
const changes = changesFromExistingPreviewCache(item, fields);
|
||||||
|
|
||||||
|
if (changes.length === 0) {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
action: "skip" as const,
|
||||||
|
reason: item.reason === "Zammad-Organisation ist lokal nicht synchronisiert." && fields.organization ? item.reason : "Kein Sync nach aktueller Auswahl.",
|
||||||
|
changes
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
action: "update" as const,
|
||||||
|
reason:
|
||||||
|
item.reason === "Kein Sync nach aktueller Auswahl." ||
|
||||||
|
item.reason === "Keine Änderung nach aktueller Auswahl." ||
|
||||||
|
item.reason === "Zammad-Organisation ist lokal nicht synchronisiert."
|
||||||
|
? "Ticket wird aktualisiert."
|
||||||
|
: item.reason,
|
||||||
|
changes
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return rebuildZammadSyncResultFromItems(result.mode, items, result.checked);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAction(item: ZammadSyncItem) {
|
||||||
|
if (item.action === "create") {
|
||||||
|
return { label: "Anlegen", variant: "success" as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.action === "update") {
|
||||||
|
return { label: "Ändern", variant: "warning" as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.action === "error") {
|
||||||
|
return { label: "Fehler", variant: "destructive" as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { label: "Kein Sync", variant: "info" as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
function zammadDetails(item: ZammadSyncItem) {
|
||||||
|
const zammad = item.zammad;
|
||||||
|
|
||||||
|
if (!zammad) {
|
||||||
|
return ["kein Treffer"];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
`ID ${formatValue(zammad.ticketId)}`,
|
||||||
|
`Gruppe ${formatZammadGroup(zammad.groupId)}`,
|
||||||
|
`Typ ${formatWorkType(zammad.workType)}`,
|
||||||
|
`Status ${formatZammadState(zammad.stateId)}`,
|
||||||
|
`Priorität ${formatZammadPriority(zammad.priorityId)}`,
|
||||||
|
`Owner ${formatValue(zammad.ownerId)}`,
|
||||||
|
`Org ${formatValue(zammad.organizationId)}`,
|
||||||
|
`Titel ${formatValue(zammad.title)}`,
|
||||||
|
`Projekt ${formatValue(zammad.teamspaceProject)}`,
|
||||||
|
`Budget ${formatMinutes(zammad.teamspaceBudgetMinutes)}`
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResultSummary({
|
||||||
|
result,
|
||||||
|
selection,
|
||||||
|
onToggle,
|
||||||
|
onToggleAll
|
||||||
|
}: {
|
||||||
|
result: ZammadSyncResult;
|
||||||
|
selection?: SelectionMap;
|
||||||
|
onToggle?: (ticketNumber: string, checked: boolean) => void;
|
||||||
|
onToggleAll?: (checked: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const selectable = selectableItems(result);
|
||||||
|
const selectedCount = selection ? selectable.filter((item) => itemIsSelected(item, selection)).length : 0;
|
||||||
|
const allSelected = selectable.length > 0 && selectedCount === selectable.length;
|
||||||
|
const metrics = [
|
||||||
|
["Geprüft", result.checked],
|
||||||
|
[result.mode === "preview" ? "Würde ändern" : "Aktualisiert", result.updated],
|
||||||
|
[result.mode === "preview" ? "Würde anlegen" : "Angelegt", result.created],
|
||||||
|
["Ohne Transfer", result.skipped],
|
||||||
|
["Nicht in Zammad", result.missingInZammad],
|
||||||
|
["Org fehlt", result.organizationMissing],
|
||||||
|
["TS/TT prüfen", result.markedForCloseReview],
|
||||||
|
["Zu Consulting", result.convertedToConsulting],
|
||||||
|
["Zu Support", result.convertedToSupport]
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid gap-2 sm:grid-cols-3 xl:grid-cols-5">
|
||||||
|
{metrics.map(([label, value]) => (
|
||||||
|
<div key={label} className="rounded-md border bg-muted/20 px-3 py-2">
|
||||||
|
<span className="block text-xs text-muted-foreground">{label}</span>
|
||||||
|
<span className="text-lg font-semibold">{value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selection ? (
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2 rounded-md border bg-muted/20 px-3 py-2">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{selectedCount} von {selectable.length} Datensatz/Datensätzen für den Transfer ausgewählt.
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button type="button" variant="outline" size="sm" disabled={selectable.length === 0} onClick={() => onToggleAll?.(true)}>
|
||||||
|
Alle wählen
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" size="sm" disabled={selectable.length === 0} onClick={() => onToggleAll?.(false)}>
|
||||||
|
Alle abwählen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="max-h-[520px] overflow-auto rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
{selection ? (
|
||||||
|
<TableHead className="w-10">
|
||||||
|
<Checkbox checked={allSelected} disabled={selectable.length === 0} onCheckedChange={(checked) => onToggleAll?.(checked === true)} />
|
||||||
|
</TableHead>
|
||||||
|
) : null}
|
||||||
|
<TableHead className="min-w-32">Ticket</TableHead>
|
||||||
|
<TableHead className="min-w-32">Aktion</TableHead>
|
||||||
|
<TableHead className="min-w-72">Daten aus Zammad</TableHead>
|
||||||
|
<TableHead className="min-w-80">Änderungen in TicketTracker</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{result.items.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={selection ? 5 : 4} className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
Keine Datensätze im Lauf.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
result.items.map((item) => {
|
||||||
|
const action = formatAction(item);
|
||||||
|
const isSelectable = item.action === "update" || item.action === "create";
|
||||||
|
const visibleChanges = visibleTicketTrackerChanges(item);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow key={`${result.mode}-${item.ticketNumber}-${item.action}`}>
|
||||||
|
{selection ? (
|
||||||
|
<TableCell>
|
||||||
|
<Checkbox
|
||||||
|
checked={itemIsSelected(item, selection)}
|
||||||
|
disabled={!isSelectable}
|
||||||
|
onCheckedChange={(checked) => onToggle?.(item.ticketNumber, checked === true)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
) : null}
|
||||||
|
<TableCell className="font-medium">{item.ticketNumber}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Badge variant={action.variant}>{action.label}</Badge>
|
||||||
|
{item.reason ? <p className="text-xs text-muted-foreground">{item.reason}</p> : null}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="max-w-xl space-y-1 text-xs text-muted-foreground">
|
||||||
|
{zammadDetails(item).map((detail) => (
|
||||||
|
<div key={detail} className="break-words">
|
||||||
|
{detail}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{visibleChanges.length > 0 ? (
|
||||||
|
<div className="space-y-1 text-xs">
|
||||||
|
{visibleChanges.map((change) => (
|
||||||
|
<div key={`${item.ticketNumber}-${change.field}`} className="break-words">
|
||||||
|
<span className="font-medium">{change.field}:</span>{" "}
|
||||||
|
<span className="text-muted-foreground">{formatChangeValue(change.field, change.from)}</span>
|
||||||
|
<span className="text-muted-foreground"> -> </span>
|
||||||
|
<span>{formatChangeValue(change.field, change.to)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">Keine Änderung.</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{result.errors.length > 0 ? (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="size-4" />
|
||||||
|
<AlertTitle>Fehler im Lauf</AlertTitle>
|
||||||
|
<AlertDescription>{result.errors.length} Datensatz/Datensätze konnten nicht vorbereitet oder verarbeitet werden.</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
|
||||||
|
const {
|
||||||
|
fields,
|
||||||
|
existingResult,
|
||||||
|
importResult,
|
||||||
|
existingSelection,
|
||||||
|
importSelection,
|
||||||
|
syncingExisting,
|
||||||
|
importingConsulting
|
||||||
|
} = useZammadSyncPageState();
|
||||||
|
const displayedExistingResult = useMemo(() => applyFieldsToExistingPreview(existingResult, fields), [existingResult, fields]);
|
||||||
|
const selectedExistingCount = useMemo(() => selectedTicketNumbers(displayedExistingResult, existingSelection).length, [displayedExistingResult, existingSelection]);
|
||||||
|
const selectedImportCount = useMemo(() => selectedTicketNumbers(importResult, importSelection).length, [importResult, importSelection]);
|
||||||
|
const zammadReady = currentUser.has_zammad_api_token && Boolean(currentUser.zammad_user_id);
|
||||||
|
|
||||||
|
function setField(key: keyof ZammadSyncExistingFields, checked: boolean) {
|
||||||
|
setZammadSyncPageState((current) => ({
|
||||||
|
fields: { ...current.fields, [key]: checked }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetRunningState() {
|
||||||
|
setZammadSyncPageState({
|
||||||
|
syncingExisting: false,
|
||||||
|
importingConsulting: false
|
||||||
|
});
|
||||||
|
toast.info("Laufstatus zurückgesetzt");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function previewExistingSync() {
|
||||||
|
if (zammadSyncPageState.syncingExisting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncFields = zammadSyncPageState.fields;
|
||||||
|
setZammadSyncPageState({ syncingExisting: true });
|
||||||
|
try {
|
||||||
|
const { jobId, result } = await previewExistingZammadTickets(syncFields);
|
||||||
|
setZammadSyncPageState({
|
||||||
|
existingResult: result,
|
||||||
|
existingPreviewJobId: jobId,
|
||||||
|
existingSelection: selectionFromResult(result)
|
||||||
|
});
|
||||||
|
toast.success("Vorschau erstellt", {
|
||||||
|
description: `${result.updated + result.created} Datensatz/Datensätze können übernommen werden.`
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Zammad-Vorschau fehlgeschlagen", {
|
||||||
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setZammadSyncPageState({ syncingExisting: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyExistingSync() {
|
||||||
|
if (zammadSyncPageState.syncingExisting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentResult = applyFieldsToExistingPreview(zammadSyncPageState.existingResult, zammadSyncPageState.fields);
|
||||||
|
const ticketNumbers = selectedTicketNumbers(currentResult, zammadSyncPageState.existingSelection);
|
||||||
|
|
||||||
|
if (ticketNumbers.length === 0) {
|
||||||
|
toast.info("Keine Datensätze ausgewählt");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!zammadSyncPageState.existingPreviewJobId) {
|
||||||
|
toast.error("Keine Vorschau im Zwischenspeicher");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncFields = zammadSyncPageState.fields;
|
||||||
|
setZammadSyncPageState({ syncingExisting: true });
|
||||||
|
try {
|
||||||
|
const { result } = await applyExistingZammadTicketsFromPreview(zammadSyncPageState.existingPreviewJobId, syncFields, ticketNumbers);
|
||||||
|
setZammadSyncPageState({
|
||||||
|
existingResult: result,
|
||||||
|
existingPreviewJobId: null,
|
||||||
|
existingSelection: {}
|
||||||
|
});
|
||||||
|
toast.success("Auswahl synchronisiert", {
|
||||||
|
description: `${result.updated} aktualisiert, ${result.skipped} ohne Transfer.`
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Zammad-Sync fehlgeschlagen", {
|
||||||
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setZammadSyncPageState({ syncingExisting: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function previewConsultingImport() {
|
||||||
|
if (zammadSyncPageState.importingConsulting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setZammadSyncPageState({ importingConsulting: true });
|
||||||
|
try {
|
||||||
|
const { jobId, result } = await previewOwnZammadConsultingTickets();
|
||||||
|
setZammadSyncPageState({
|
||||||
|
importResult: result,
|
||||||
|
importPreviewJobId: jobId,
|
||||||
|
importSelection: selectionFromResult(result)
|
||||||
|
});
|
||||||
|
toast.success("Import-Vorschau erstellt", {
|
||||||
|
description: `${result.created} Consulting-Ticket(s) können angelegt werden.`
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Zammad-Importvorschau fehlgeschlagen", {
|
||||||
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setZammadSyncPageState({ importingConsulting: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyConsultingImport() {
|
||||||
|
if (zammadSyncPageState.importingConsulting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ticketNumbers = selectedTicketNumbers(zammadSyncPageState.importResult, zammadSyncPageState.importSelection);
|
||||||
|
|
||||||
|
if (ticketNumbers.length === 0) {
|
||||||
|
toast.info("Keine Datensätze ausgewählt");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!zammadSyncPageState.importPreviewJobId) {
|
||||||
|
toast.error("Keine Import-Vorschau im Zwischenspeicher");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setZammadSyncPageState({ importingConsulting: true });
|
||||||
|
try {
|
||||||
|
const { result } = await applyOwnZammadConsultingTicketsFromPreview(zammadSyncPageState.importPreviewJobId, ticketNumbers);
|
||||||
|
setZammadSyncPageState({
|
||||||
|
importResult: result,
|
||||||
|
importPreviewJobId: null,
|
||||||
|
importSelection: {}
|
||||||
|
});
|
||||||
|
toast.success("Auswahl importiert", {
|
||||||
|
description: `${result.created} angelegt, ${result.skipped} ohne Transfer.`
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Zammad-Import fehlgeschlagen", {
|
||||||
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setZammadSyncPageState({ importingConsulting: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleExisting(ticketNumber: string, checked: boolean) {
|
||||||
|
setZammadSyncPageState((current) => ({
|
||||||
|
existingSelection: { ...current.existingSelection, [ticketNumber]: checked }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleExistingAll(checked: boolean) {
|
||||||
|
const currentResult = applyFieldsToExistingPreview(zammadSyncPageState.existingResult, zammadSyncPageState.fields);
|
||||||
|
|
||||||
|
if (!currentResult) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setZammadSyncPageState({
|
||||||
|
existingSelection: Object.fromEntries(selectableItems(currentResult).map((item) => [item.ticketNumber, checked]))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleImport(ticketNumber: string, checked: boolean) {
|
||||||
|
setZammadSyncPageState((current) => ({
|
||||||
|
importSelection: { ...current.importSelection, [ticketNumber]: checked }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleImportAll(checked: boolean) {
|
||||||
|
if (!zammadSyncPageState.importResult) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setZammadSyncPageState({
|
||||||
|
importSelection: Object.fromEntries(selectableItems(zammadSyncPageState.importResult).map((item) => [item.ticketNumber, checked]))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold tracking-normal sm:text-2xl">Zammad-Sync</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">Tickets und Consulting-Projekte aus Zammad in TicketTracker abgleichen.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!zammadReady ? (
|
||||||
|
<Alert variant="warning">
|
||||||
|
<AlertCircle className="size-4" />
|
||||||
|
<AlertTitle>Zammad-Token fehlt</AlertTitle>
|
||||||
|
<AlertDescription>Bitte im Profil den persönlichen Zammad API-Token speichern, damit die User-ID bekannt ist.</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="p-4">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<RefreshCw className="size-5 text-muted-foreground" />
|
||||||
|
Bestehende Tickets aktualisieren
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Vorhandene TicketTracker-Tickets werden zuerst vorbereitet und erst nach Auswahl bestätigt.</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Badge variant={zammadReady ? "success" : "secondary"}>{zammadReady ? "bereit" : "nicht bereit"}</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4 px-4 pb-4">
|
||||||
|
<div className="grid gap-2 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{fieldOptions.map((option) => (
|
||||||
|
<label key={option.key} className={cn("flex min-w-0 gap-3 rounded-md border p-3", fields[option.key] ? "bg-muted/30" : "bg-background")}>
|
||||||
|
<Checkbox checked={fields[option.key]} onCheckedChange={(checked) => setField(option.key, checked === true)} />
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block text-sm font-medium">{option.label}</span>
|
||||||
|
<span className="block text-xs text-muted-foreground">{option.description}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
|
{syncingExisting ? (
|
||||||
|
<Button type="button" variant="outline" onClick={resetRunningState}>
|
||||||
|
Laufstatus zurücksetzen
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{displayedExistingResult?.mode === "preview" ? (
|
||||||
|
<Button type="button" disabled={!zammadReady || syncingExisting || selectedExistingCount === 0} onClick={() => void applyExistingSync()}>
|
||||||
|
<CheckCircle2 className="size-4" />
|
||||||
|
{syncingExisting ? "Synchronisiert..." : `Auswahl bestätigen (${selectedExistingCount})`}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button type="button" variant={displayedExistingResult?.mode === "preview" ? "outline" : "default"} disabled={!zammadReady || syncingExisting} onClick={() => void previewExistingSync()}>
|
||||||
|
<DatabaseZap className="size-4" />
|
||||||
|
{syncingExisting ? "Bereitet vor..." : "Vorschau erstellen"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{displayedExistingResult ? (
|
||||||
|
<ResultSummary
|
||||||
|
result={displayedExistingResult}
|
||||||
|
selection={displayedExistingResult.mode === "preview" ? existingSelection : undefined}
|
||||||
|
onToggle={toggleExisting}
|
||||||
|
onToggleAll={toggleExistingAll}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="p-4">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<DownloadCloud className="size-5 text-muted-foreground" />
|
||||||
|
Meine Consulting-Tickets importieren
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Consulting-Tickets aus Zammad mit deiner Owner-ID werden erst nach Vorschau und Auswahl angelegt.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4 px-4 pb-4">
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>Neue Consulting-Tickets übernehmen Zammad-Daten immer vollständig. Geschlossene Zammad-Tickets werden in diesem Lauf nicht importiert.</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
|
{importingConsulting ? (
|
||||||
|
<Button type="button" variant="outline" onClick={resetRunningState}>
|
||||||
|
Laufstatus zurücksetzen
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{importResult?.mode === "preview" ? (
|
||||||
|
<Button type="button" disabled={!zammadReady || importingConsulting || selectedImportCount === 0} onClick={() => void applyConsultingImport()}>
|
||||||
|
<CheckCircle2 className="size-4" />
|
||||||
|
{importingConsulting ? "Importiert..." : `Auswahl bestätigen (${selectedImportCount})`}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button type="button" variant={importResult?.mode === "preview" ? "outline" : "default"} disabled={!zammadReady || importingConsulting} onClick={() => void previewConsultingImport()}>
|
||||||
|
<DownloadCloud className="size-4" />
|
||||||
|
{importingConsulting ? "Bereitet vor..." : "Vorschau erstellen"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{importResult ? (
|
||||||
|
<ResultSummary result={importResult} selection={importResult.mode === "preview" ? importSelection : undefined} onToggle={toggleImport} onToggleAll={toggleImportAll} />
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user