Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"@t3-oss/env-nextjs": "^0.13.11",
"@tailwindcss/typography": "^0.5.20",
"@tanstack/react-query": "^5.102.8",
"@tanstack/react-table": "^9.2.4",
"@tiptap/extension-image": "^3.31.3",
"@tiptap/extension-link": "^3.31.3",
"@tiptap/extension-placeholder": "^3.31.3",
Expand Down
43 changes: 43 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

45 changes: 45 additions & 0 deletions src/app/_components/dashboard/content-status.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { ContentStatus } from "~/generated/prisma/enums";

/**
* Ein Freigabestatus, eine Beschriftung, eine Farbe — Karten, Tabellen und
* Filterlisten der Übersichten greifen auf dieselbe Tabelle zu, damit derselbe
* Status nicht je nach Ansicht anders heißt.
*/
export const CONTENT_STATUS_LABELS: Record<ContentStatus, string> = {
DRAFT: "Entwurf",
PENDING: "Zur Prüfung",
APPROVED: "Veröffentlicht",
REJECTED: "Abgelehnt",
ARCHIVED: "Archiviert",
};

export const CONTENT_STATUS_BADGE_CLASSES: Record<ContentStatus, string> = {
DRAFT: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300",
PENDING:
"bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300",
APPROVED:
"bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300",
REJECTED: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300",
ARCHIVED: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400",
};

/** Set-Filter-Optionen für eine Statusspalte. */
export const CONTENT_STATUS_OPTIONS = (
Object.keys(CONTENT_STATUS_LABELS) as ContentStatus[]
).map((value) => ({ value, label: CONTENT_STATUS_LABELS[value] }));

export function ContentStatusBadge({
status,
className,
}: {
status: ContentStatus;
className?: string;
}) {
return (
<span
className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium whitespace-nowrap ${CONTENT_STATUS_BADGE_CLASSES[status]} ${className ?? ""}`}
>
{CONTENT_STATUS_LABELS[status]}
</span>
);
}
57 changes: 57 additions & 0 deletions src/app/_components/dashboard/course-invoices-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"use client";

import Link from "next/link";
import { ReceiptTextIcon } from "lucide-react";
import { useSession } from "@/lib/auth";
import { api } from "@/trpc/react";

interface CourseInvoicesButtonProps {
courseId: string;
/** `primary` for pages where invoicing is the point, `secondary` elsewhere. */
variant?: "primary" | "secondary";
/** Shortens the label to "Rechnungen" where header space is tight. */
short?: boolean;
className?: string;
}

/**
* Der Sprung in die Rechnungsliste eines Kurses — auf jeder Kursseite derselbe
* Knopf, damit man ihn nicht auf jeder Seite woanders suchen muss.
*
* Ob er erscheint, entscheidet der Server (`canManageCourseInvoices`), nicht die
* Seite: sonst driften Knopf und Guard auseinander und es entsteht entweder ein
* Knopf, der 403 wirft, oder eine Berechtigung ohne Knopf.
*
* Bewusst auch dann sichtbar, wenn für den Kurs `invoicingEnabled` aus ist: die
* Rechnungsseite zeigt in dem Fall die bereits bestehenden Rechnungen samt
* Hinweisbanner, und genau dorthin will man dann.
*/
export function CourseInvoicesButton({
courseId,
variant = "secondary",
short = false,
className,
}: CourseInvoicesButtonProps) {
const { data: session } = useSession();
const { data: access } = api.invoices.canManageCourseInvoices.useQuery(
{ courseId },
{ enabled: !!courseId && !!session?.user },
);

if (!access?.canManage) return null;

const styles =
variant === "primary"
? "bg-primary hover:bg-primary/90 text-white"
: "dark:border-dark-border dark:bg-dark-surface dark:text-dark-text border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700";

return (
<Link
href={`/dashboard/courses/${courseId}/invoices`}
className={`inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors ${styles} ${className ?? ""}`}
>
<ReceiptTextIcon className="h-4 w-4" />
{short ? "Rechnungen" : "Rechnungen verwalten"}
</Link>
);
}
41 changes: 7 additions & 34 deletions src/app/_components/dashboard/dashboard-course-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import Link from "next/link";
import { getDistrictColor } from "@/lib/district-color";
import { coursePath } from "@/lib/slug";
import type { ContentStatus, CourseType } from "~/generated/prisma/enums";
import {
CONTENT_STATUS_BADGE_CLASSES,
CONTENT_STATUS_LABELS,
} from "./content-status";
import {
Calendar,
MapPin,
Expand Down Expand Up @@ -35,37 +39,6 @@ interface DashboardCourseCardProps {
createdAt?: Date;
}

const statusConfig: Record<
ContentStatus,
{ label: string; bgColor: string; textColor: string }
> = {
DRAFT: {
label: "Entwurf",
bgColor: "bg-gray-100 dark:bg-gray-800",
textColor: "text-gray-700 dark:text-gray-300",
},
PENDING: {
label: "Zur Prüfung",
bgColor: "bg-yellow-100 dark:bg-yellow-900/30",
textColor: "text-yellow-800 dark:text-yellow-300",
},
APPROVED: {
label: "Veröffentlicht",
bgColor: "bg-green-100 dark:bg-green-900/30",
textColor: "text-green-800 dark:text-green-300",
},
REJECTED: {
label: "Abgelehnt",
bgColor: "bg-red-100 dark:bg-red-900/30",
textColor: "text-red-800 dark:text-red-300",
},
ARCHIVED: {
label: "Archiviert",
bgColor: "bg-gray-100 dark:bg-gray-800",
textColor: "text-gray-600 dark:text-gray-400",
},
};

const courseTypeLabels: Record<CourseType, string> = {
LEHRGANG: "Lehrgang",
FREIZEIT: "Freizeit",
Expand Down Expand Up @@ -94,7 +67,7 @@ export default function DashboardCourseCard({
createdAt,
}: DashboardCourseCardProps) {
const districtColor = getDistrictColor(district);
const statusInfo = statusConfig[status];
const statusClasses = CONTENT_STATUS_BADGE_CLASSES[status];
const isFull = maxParticipants ? confirmedCount >= maxParticipants : false;
const isDeadlinePassed = registrationDeadline
? new Date(registrationDeadline) < new Date()
Expand Down Expand Up @@ -137,9 +110,9 @@ export default function DashboardCourseCard({
<div className="mb-2.5 flex flex-wrap items-start justify-between gap-x-3 gap-y-1.5">
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-1.5">
<span
className={`inline-flex max-w-full shrink-0 items-center rounded-md px-2 py-1 text-xs font-medium ${statusInfo.bgColor} ${statusInfo.textColor}`}
className={`inline-flex max-w-full shrink-0 items-center rounded-md px-2 py-1 text-xs font-medium ${statusClasses}`}
>
<span className="truncate">{statusInfo.label}</span>
<span className="truncate">{CONTENT_STATUS_LABELS[status]}</span>
</span>
{isRegistrationNotOpenYet && registrationOpen ? (
<span className="inline-flex shrink-0 rounded-md bg-purple-500/10 px-2 py-1 text-xs font-medium text-purple-900 dark:text-purple-200">
Expand Down
Loading