Initial TicketTracker release
This commit is contained in:
1 parent
d1da86beab
commit
c2063fe1ed
11 files changed
+1124
-175
No files matched your search
@@ -54,6 +54,7 @@ import type {
|
||||
DashboardWidget,
|
||||
DashboardWidgetType,
|
||||
PeriodOverview,
|
||||
ProjectTeamspaceEntry,
|
||||
ProjectSummary,
|
||||
RecurringBilling,
|
||||
StatisticsOverview,
|
||||
@@ -571,6 +572,25 @@ function projectConsumptionPercent(project: ProjectSummary) {
|
||||
return Math.round(((project.consumption_minutes ?? 0) / budgetMinutes) * 100);
|
||||
}
|
||||
|
||||
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 openRatingFilterGroup(filter: string) {
|
||||
if (filter === "fixedBilling" || filter === "manualBilling") {
|
||||
return "source";
|
||||
@@ -1648,18 +1668,48 @@ function ProjectBudgetWidget({ widget, editing, projects, stats, onNavigate, onU
|
||||
const consumptionMinutes = project.consumption_minutes ?? 0;
|
||||
const budgetMinutes = project.hour_budget_minutes ?? 0;
|
||||
const percent = projectConsumptionPercent(project);
|
||||
const entries = projectTeamspaceEntries(project);
|
||||
const hasMultipleTeamspaceEntries = entries.length > 1;
|
||||
|
||||
return (
|
||||
<button key={project.id} type="button" className="block w-full min-w-0 rounded-md px-2 py-1.5 text-left hover:bg-muted" onClick={() => onNavigate(`/tickets/${project.ticket_id}?month=${currentMonth()}`)}>
|
||||
<span className="block truncate text-xs font-medium">{project.title || project.ticket_number}</span>
|
||||
<span className="block truncate text-[11px] text-muted-foreground">
|
||||
{project.ticket_number} · {budgetMinutes > 0 ? `${decimalHours(consumptionMinutes)} / ${decimalHours(budgetMinutes)} · ${percent ?? 0}%` : "ohne Kontingent"}
|
||||
{project.ticket_number} · {hasMultipleTeamspaceEntries ? `${entries.length} Teamspace · ` : ""}{budgetMinutes > 0 ? `${decimalHours(consumptionMinutes)} / ${decimalHours(budgetMinutes)} · ${percent ?? 0}%` : "ohne Kontingent"}
|
||||
</span>
|
||||
{percent !== null ? (
|
||||
<span className="mt-1 block h-1.5 overflow-hidden rounded-full bg-muted" title={`${percent}% Verbrauch`} aria-label={`${percent}% Verbrauch`}>
|
||||
<span className={cn("block h-full rounded-full", consumptionBarClass(percent))} style={{ width: `${Math.min(100, percent)}%` }} />
|
||||
</span>
|
||||
) : null}
|
||||
{hasMultipleTeamspaceEntries ? (
|
||||
<span className="mt-1 grid gap-1">
|
||||
{entries.map((entry) => {
|
||||
const entryBudgetMinutes = entry.hour_budget_minutes ?? 0;
|
||||
const entryConsumptionMinutes = entry.consumption_minutes ?? 0;
|
||||
const entryPercent = entryBudgetMinutes > 0 ? Math.round((entryConsumptionMinutes / entryBudgetMinutes) * 100) : null;
|
||||
|
||||
return (
|
||||
<span key={entry.id} className="block">
|
||||
<span className="flex min-w-0 items-center justify-between gap-2 text-[10px] text-muted-foreground">
|
||||
<span className="truncate">
|
||||
{entry.teamspace_project || (entry.is_default ? "Standard" : "Ohne Teamspace-ID")}
|
||||
{entry.is_default ? " · Standard" : ""}
|
||||
</span>
|
||||
<span className="shrink-0">
|
||||
{entryBudgetMinutes > 0 ? `${decimalHours(entryConsumptionMinutes)} / ${decimalHours(entryBudgetMinutes)}` : decimalHours(entryConsumptionMinutes)}
|
||||
</span>
|
||||
</span>
|
||||
{entryPercent !== null ? (
|
||||
<span className="mt-0.5 block h-1 overflow-hidden rounded-full bg-muted">
|
||||
<span className={cn("block h-full rounded-full", consumptionBarClass(entryPercent))} style={{ width: `${Math.min(100, entryPercent)}%` }} />
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -1973,6 +2023,46 @@ function NotesWidget({ widget, editing, onUpdateConfig }: WidgetContentProps) {
|
||||
const { notes, activeNoteId } = readWidgetNotes(widget.config);
|
||||
const activeNote = notes.find((note) => note.id === activeNoteId) ?? notes[0];
|
||||
const tabsScrollerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [tabScrollState, setTabScrollState] = useState({ canScrollLeft: false, canScrollRight: false });
|
||||
|
||||
function updateTabScrollState() {
|
||||
const scroller = tabsScrollerRef.current;
|
||||
|
||||
if (!scroller) {
|
||||
setTabScrollState({ canScrollLeft: false, canScrollRight: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const maxScrollLeft = scroller.scrollWidth - scroller.clientWidth;
|
||||
setTabScrollState({
|
||||
canScrollLeft: scroller.scrollLeft > 1,
|
||||
canScrollRight: scroller.scrollLeft < maxScrollLeft - 1
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
updateTabScrollState();
|
||||
const scroller = tabsScrollerRef.current;
|
||||
|
||||
if (!scroller) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleScroll = () => updateTabScrollState();
|
||||
const handleResize = () => updateTabScrollState();
|
||||
|
||||
scroller.addEventListener("scroll", handleScroll, { passive: true });
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
return () => {
|
||||
scroller.removeEventListener("scroll", handleScroll);
|
||||
window.removeEventListener("resize", handleResize);
|
||||
};
|
||||
}, [notes.length]);
|
||||
|
||||
useEffect(() => {
|
||||
updateTabScrollState();
|
||||
}, [activeNoteId, notes.length]);
|
||||
|
||||
function saveNotes(nextNotes: DashboardNote[], nextActiveNoteId = activeNoteId) {
|
||||
const safeActiveNoteId = nextNotes.some((note) => note.id === nextActiveNoteId) ? nextActiveNoteId : nextNotes[0]?.id;
|
||||
@@ -2005,10 +2095,27 @@ function NotesWidget({ widget, editing, onUpdateConfig }: WidgetContentProps) {
|
||||
return (
|
||||
<Tabs value={activeNoteId} onValueChange={(value) => onUpdateConfig({ activeNoteId: value })} className="flex h-full min-h-0 overflow-hidden gap-1">
|
||||
<div className="flex h-8 min-w-0 shrink-0 items-center gap-1 overflow-hidden">
|
||||
<Button type="button" size="icon" variant="ghost" className="size-7 shrink-0" onClick={() => scrollTabs(-1)}>
|
||||
<Button type="button" size="icon" variant="ghost" className="size-7 shrink-0" disabled={!tabScrollState.canScrollLeft} onClick={() => scrollTabs(-1)}>
|
||||
<ChevronLeft className="size-3.5" />
|
||||
</Button>
|
||||
<div ref={tabsScrollerRef} className="min-w-0 flex-1 overflow-x-auto overflow-y-hidden">
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-y-0 left-0 z-10 w-8 bg-gradient-to-r from-card to-transparent transition-opacity",
|
||||
tabScrollState.canScrollLeft ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-y-0 right-0 z-10 w-8 bg-gradient-to-l from-card to-transparent transition-opacity",
|
||||
tabScrollState.canScrollRight ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
ref={tabsScrollerRef}
|
||||
className="min-w-0 flex-1 overflow-x-auto overflow-y-hidden [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
onScroll={updateTabScrollState}
|
||||
>
|
||||
<TabsList variant="line" className="h-8 w-max min-w-max justify-start rounded-none p-0 pb-1">
|
||||
{notes.map((note) => (
|
||||
<TabsTrigger key={note.id} value={note.id} className="h-7 w-32 shrink-0 flex-none px-2 text-xs">
|
||||
@@ -2016,8 +2123,9 @@ function NotesWidget({ widget, editing, onUpdateConfig }: WidgetContentProps) {
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="button" size="icon" variant="ghost" className="size-7 shrink-0" onClick={() => scrollTabs(1)}>
|
||||
<Button type="button" size="icon" variant="ghost" className="size-7 shrink-0" disabled={!tabScrollState.canScrollRight} onClick={() => scrollTabs(1)}>
|
||||
<ChevronRight className="size-3.5" />
|
||||
</Button>
|
||||
<Button type="button" size="icon" variant="ghost" className="size-7 shrink-0" onClick={addNote}>
|
||||
|
||||
Reference in new issue
Block a user