localization for ntfy and mail
This commit is contained in:
@@ -10,6 +10,7 @@ COPY package*.json tsconfig.json ./
|
|||||||
RUN npm install
|
RUN npm install
|
||||||
|
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
|
COPY shared ./shared
|
||||||
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,8 @@ docker compose up --build
|
|||||||
| `AUTH_TOKEN_EXPIRES_IN_HOURS` | `12` | Lebensdauer der Tokens. |
|
| `AUTH_TOKEN_EXPIRES_IN_HOURS` | `12` | Lebensdauer der Tokens. |
|
||||||
| `PAPERLESS_BASE_URL` | *(leer)* | API-URL deiner paperless-ngx Instanz. |
|
| `PAPERLESS_BASE_URL` | *(leer)* | API-URL deiner paperless-ngx Instanz. |
|
||||||
| `PAPERLESS_EXTERNAL_URL` | *(leer)* | Optionale externe URL für Direktlinks im Browser. |
|
| `PAPERLESS_EXTERNAL_URL` | *(leer)* | Optionale externe URL für Direktlinks im Browser. |
|
||||||
|
| `APP_EXTERNAL_URL` | *(leer)* | Optionale externe URL der App (z. B. für Links im iCal-Feed), überschreibt den Host der eingehenden Anfrage. |
|
||||||
|
| `APP_LOCALE` | `de` | Sprache für systemseitige Nachrichten (z. B. ntfy, E-Mail-Betreff); derzeit `de` oder `en`. |
|
||||||
| `PAPERLESS_TOKEN` | *(leer)* | API-Token aus paperless-ngx. |
|
| `PAPERLESS_TOKEN` | *(leer)* | API-Token aus paperless-ngx. |
|
||||||
| `NTFY_SERVER_URL` / `NTFY_TOPIC` | *(leer)* | Aktiviert ntfy-Push-Benachrichtigungen. |
|
| `NTFY_SERVER_URL` / `NTFY_TOPIC` | *(leer)* | Aktiviert ntfy-Push-Benachrichtigungen. |
|
||||||
| `NTFY_TOKEN` / `NTFY_PRIORITY` | *(leer)* | Optionaler Bearer-Token & Priorität. |
|
| `NTFY_TOKEN` / `NTFY_PRIORITY` | *(leer)* | Optionaler Bearer-Token & Priorität. |
|
||||||
@@ -100,6 +102,12 @@ docker compose up --build
|
|||||||
|
|
||||||
Paperless ist **nicht verpflichtend**. Lässt du `PAPERLESS_*` leer, bleibt die Vertragsverwaltung voll funktionsfähig (Deadlines, Benachrichtigungen, iCal, ntfy, etc.). Die UI blendet paperless-spezifische Funktionen aus bzw. deaktiviert Buttons und zeigt Hinweise an. Sobald du später eine URL/Token hinterlegst, werden Suche und Direktlinks automatisch freigeschaltet.
|
Paperless ist **nicht verpflichtend**. Lässt du `PAPERLESS_*` leer, bleibt die Vertragsverwaltung voll funktionsfähig (Deadlines, Benachrichtigungen, iCal, ntfy, etc.). Die UI blendet paperless-spezifische Funktionen aus bzw. deaktiviert Buttons und zeigt Hinweise an. Sobald du später eine URL/Token hinterlegst, werden Suche und Direktlinks automatisch freigeschaltet.
|
||||||
|
|
||||||
|
### iCal-Feed hinter eigenem Host bereitstellen
|
||||||
|
|
||||||
|
Möchtest du den iCal-Feed unter einer separaten Domain ausliefern (z. B. `calendar.example.com`), kannst du den Request über einen Reverse Proxy auf `/api/calendar/feed.ics` weiterleiten. Damit Links innerhalb des Feeds korrekt auf deine eigentliche App zeigen, setze `APP_EXTERNAL_URL` (oder hinterlege den Wert in den Einstellungen unter „Externe URL der App“). Ist das Feld leer, verwendet der Dienst den Host der eingehenden Anfrage.
|
||||||
|
|
||||||
|
Die Sprache der automatisch verschickten Benachrichtigungen (Mail, ntfy) kannst du über `APP_LOCALE` bzw. das Feld „Sprache für Benachrichtigungen“ in den Einstellungen auf Deutsch oder Englisch festlegen.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## API-Übersicht
|
## API-Übersicht
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ services:
|
|||||||
# - MAIL_USE_TLS=true
|
# - MAIL_USE_TLS=true
|
||||||
# - MAIL_FROM=contract-monitor@example.com
|
# - MAIL_FROM=contract-monitor@example.com
|
||||||
# - MAIL_TO=you@example.com
|
# - MAIL_TO=you@example.com
|
||||||
|
# - APP_EXTERNAL_URL=https://contracts.example.com
|
||||||
|
# - APP_LOCALE=en
|
||||||
# Optional: festen iCal-Token vorgeben
|
# Optional: festen iCal-Token vorgeben
|
||||||
# - ICAL_SECRET=replace-with-secret
|
# - ICAL_SECRET=replace-with-secret
|
||||||
volumes:
|
volumes:
|
||||||
@@ -39,7 +41,8 @@ services:
|
|||||||
- "8080:8000"
|
- "8080:8000"
|
||||||
contract-companion-ui:
|
contract-companion-ui:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: .
|
||||||
|
dockerfile: frontend/Dockerfile
|
||||||
container_name: contract-companion-ui
|
container_name: contract-companion-ui
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -2,17 +2,23 @@ FROM node:20-alpine AS build
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package*.json ./
|
# Install dependencies
|
||||||
|
COPY frontend/package*.json ./
|
||||||
RUN npm install
|
RUN npm install
|
||||||
|
|
||||||
COPY . .
|
# Copy source files
|
||||||
|
COPY frontend/tsconfig*.json ./
|
||||||
|
COPY frontend/vite.config.ts ./
|
||||||
|
COPY frontend/index.html ./
|
||||||
|
COPY frontend/src ./src
|
||||||
|
COPY shared ./shared
|
||||||
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM nginx:alpine
|
FROM nginx:alpine
|
||||||
|
|
||||||
COPY --from=build /app/dist /usr/share/nginx/html
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
COPY frontend/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ export interface ServerConfig {
|
|||||||
databasePath: string;
|
databasePath: string;
|
||||||
paperlessBaseUrl: string | null;
|
paperlessBaseUrl: string | null;
|
||||||
paperlessExternalUrl: string | null;
|
paperlessExternalUrl: string | null;
|
||||||
|
appExternalUrl: string | null;
|
||||||
|
appLocale: string;
|
||||||
paperlessConfigured: boolean;
|
paperlessConfigured: boolean;
|
||||||
schedulerIntervalMinutes: number;
|
schedulerIntervalMinutes: number;
|
||||||
alertDaysBefore: number;
|
alertDaysBefore: number;
|
||||||
@@ -26,6 +28,8 @@ export interface SettingsResponse {
|
|||||||
values: {
|
values: {
|
||||||
paperlessBaseUrl: string | null;
|
paperlessBaseUrl: string | null;
|
||||||
paperlessExternalUrl: string | null;
|
paperlessExternalUrl: string | null;
|
||||||
|
appExternalUrl: string | null;
|
||||||
|
appLocale: string;
|
||||||
schedulerIntervalMinutes: number;
|
schedulerIntervalMinutes: number;
|
||||||
alertDaysBefore: number;
|
alertDaysBefore: number;
|
||||||
mailServer: string | null;
|
mailServer: string | null;
|
||||||
@@ -51,6 +55,8 @@ export interface SettingsResponse {
|
|||||||
export type UpdateSettingsPayload = Partial<{
|
export type UpdateSettingsPayload = Partial<{
|
||||||
paperlessBaseUrl: string | null;
|
paperlessBaseUrl: string | null;
|
||||||
paperlessExternalUrl: string | null;
|
paperlessExternalUrl: string | null;
|
||||||
|
appExternalUrl: string | null;
|
||||||
|
appLocale: string;
|
||||||
paperlessToken: string | null;
|
paperlessToken: string | null;
|
||||||
schedulerIntervalMinutes: number;
|
schedulerIntervalMinutes: number;
|
||||||
alertDaysBefore: number;
|
alertDaysBefore: number;
|
||||||
|
|||||||
@@ -79,6 +79,7 @@
|
|||||||
"edit": "Bearbeiten",
|
"edit": "Bearbeiten",
|
||||||
"deleted": "Vertrag gelöscht",
|
"deleted": "Vertrag gelöscht",
|
||||||
"deleteError": "Löschen fehlgeschlagen",
|
"deleteError": "Löschen fehlgeschlagen",
|
||||||
|
"categoryAddTitle": "Kategorie hinzufügen",
|
||||||
"categoryAdd": "Neue Kategorie hinzufügen",
|
"categoryAdd": "Neue Kategorie hinzufügen",
|
||||||
"categoryAddDescription": "Erstelle eine neue Kategorie, die du sofort auswählen kannst.",
|
"categoryAddDescription": "Erstelle eine neue Kategorie, die du sofort auswählen kannst.",
|
||||||
"categoryNameLabel": "Name der Kategorie",
|
"categoryNameLabel": "Name der Kategorie",
|
||||||
@@ -86,6 +87,7 @@
|
|||||||
"categoryNameRequired": "Bitte einen Kategorienamen angeben.",
|
"categoryNameRequired": "Bitte einen Kategorienamen angeben.",
|
||||||
"categoryCreated": "Kategorie \"{{name}}\" gespeichert",
|
"categoryCreated": "Kategorie \"{{name}}\" gespeichert",
|
||||||
"categoryCreateError": "Kategorie konnte nicht angelegt werden",
|
"categoryCreateError": "Kategorie konnte nicht angelegt werden",
|
||||||
|
"categoryExists": "Kategorie \"{{name}}\" ist bereits vorhanden",
|
||||||
"categoryNone": "Keine Kategorie",
|
"categoryNone": "Keine Kategorie",
|
||||||
"categoryLoading": "Kategorien werden geladen…",
|
"categoryLoading": "Kategorien werden geladen…",
|
||||||
"categoryLoadError": "Kategorien konnten nicht geladen werden."
|
"categoryLoadError": "Kategorien konnten nicht geladen werden."
|
||||||
@@ -177,7 +179,14 @@
|
|||||||
"icalFeedUrl": "Feed-URL",
|
"icalFeedUrl": "Feed-URL",
|
||||||
"paperlessApiUrl": "Paperless API URL",
|
"paperlessApiUrl": "Paperless API URL",
|
||||||
"paperlessExternalUrl": "Paperless externe URL (für Direktlink)",
|
"paperlessExternalUrl": "Paperless externe URL (für Direktlink)",
|
||||||
|
"appExternalUrl": "Externe URL der App",
|
||||||
"paperlessExample": "https://paperless.example.com",
|
"paperlessExample": "https://paperless.example.com",
|
||||||
|
"appExternalExample": "https://contracts.example.com",
|
||||||
|
"appExternalUrlHelp": "Diese URL wird in iCal-Feeds und Links verwendet. Leer lassen, um Host des Aufrufs zu nutzen.",
|
||||||
|
"appLocaleLabel": "Sprache für Benachrichtigungen",
|
||||||
|
"appLocaleHelp": "Bestimmt die Sprache für ntfy-Pushs und Mail-Betreffs.",
|
||||||
|
"appLocaleGerman": "Deutsch",
|
||||||
|
"appLocaleEnglish": "Englisch",
|
||||||
"paperlessToken": "Paperless Token",
|
"paperlessToken": "Paperless Token",
|
||||||
"paperlessTokenNew": "Neuen Token hinterlegen",
|
"paperlessTokenNew": "Neuen Token hinterlegen",
|
||||||
"paperlessTokenPlaceholder": "Token eingeben",
|
"paperlessTokenPlaceholder": "Token eingeben",
|
||||||
@@ -236,6 +245,7 @@
|
|||||||
"categoryNameRequired": "Bitte einen Namen für die Kategorie eingeben.",
|
"categoryNameRequired": "Bitte einen Namen für die Kategorie eingeben.",
|
||||||
"categoryCreated": "Kategorie \"{{name}}\" gespeichert",
|
"categoryCreated": "Kategorie \"{{name}}\" gespeichert",
|
||||||
"categoryCreateError": "Kategorie konnte nicht angelegt werden",
|
"categoryCreateError": "Kategorie konnte nicht angelegt werden",
|
||||||
|
"categoryExists": "Kategorie \"{{name}}\" ist bereits vorhanden",
|
||||||
"categoryDeleted": "Kategorie gelöscht",
|
"categoryDeleted": "Kategorie gelöscht",
|
||||||
"categoryDeleteError": "Kategorie konnte nicht gelöscht werden",
|
"categoryDeleteError": "Kategorie konnte nicht gelöscht werden",
|
||||||
"categoryEmpty": "Noch keine Kategorien vorhanden.",
|
"categoryEmpty": "Noch keine Kategorien vorhanden.",
|
||||||
|
|||||||
@@ -79,6 +79,7 @@
|
|||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"deleted": "Contract deleted",
|
"deleted": "Contract deleted",
|
||||||
"deleteError": "Failed to delete",
|
"deleteError": "Failed to delete",
|
||||||
|
"categoryAddTitle": "Add category",
|
||||||
"categoryAdd": "Add new category",
|
"categoryAdd": "Add new category",
|
||||||
"categoryAddDescription": "Create a new category you can select immediately.",
|
"categoryAddDescription": "Create a new category you can select immediately.",
|
||||||
"categoryNameLabel": "Category name",
|
"categoryNameLabel": "Category name",
|
||||||
@@ -86,6 +87,7 @@
|
|||||||
"categoryNameRequired": "Please provide a category name.",
|
"categoryNameRequired": "Please provide a category name.",
|
||||||
"categoryCreated": "Category \"{{name}}\" saved",
|
"categoryCreated": "Category \"{{name}}\" saved",
|
||||||
"categoryCreateError": "Failed to create category",
|
"categoryCreateError": "Failed to create category",
|
||||||
|
"categoryExists": "Category \"{{name}}\" already exists",
|
||||||
"categoryNone": "No category",
|
"categoryNone": "No category",
|
||||||
"categoryLoading": "Loading categories…",
|
"categoryLoading": "Loading categories…",
|
||||||
"categoryLoadError": "Could not load categories."
|
"categoryLoadError": "Could not load categories."
|
||||||
@@ -177,7 +179,14 @@
|
|||||||
"icalFeedUrl": "Feed URL",
|
"icalFeedUrl": "Feed URL",
|
||||||
"paperlessApiUrl": "Paperless API URL",
|
"paperlessApiUrl": "Paperless API URL",
|
||||||
"paperlessExternalUrl": "Paperless external URL (for direct link)",
|
"paperlessExternalUrl": "Paperless external URL (for direct link)",
|
||||||
|
"appExternalUrl": "App external URL",
|
||||||
"paperlessExample": "https://paperless.example.com",
|
"paperlessExample": "https://paperless.example.com",
|
||||||
|
"appExternalExample": "https://contracts.example.com",
|
||||||
|
"appExternalUrlHelp": "Used for links and iCal feeds. Leave empty to use the request host.",
|
||||||
|
"appLocaleLabel": "Notification language",
|
||||||
|
"appLocaleHelp": "Controls the language used in ntfy pushes and email subjects.",
|
||||||
|
"appLocaleGerman": "German",
|
||||||
|
"appLocaleEnglish": "English",
|
||||||
"paperlessToken": "Paperless token",
|
"paperlessToken": "Paperless token",
|
||||||
"paperlessTokenNew": "Provide new token",
|
"paperlessTokenNew": "Provide new token",
|
||||||
"paperlessTokenPlaceholder": "Enter token",
|
"paperlessTokenPlaceholder": "Enter token",
|
||||||
@@ -236,6 +245,7 @@
|
|||||||
"categoryNameRequired": "Please enter a category name.",
|
"categoryNameRequired": "Please enter a category name.",
|
||||||
"categoryCreated": "Category \"{{name}}\" saved",
|
"categoryCreated": "Category \"{{name}}\" saved",
|
||||||
"categoryCreateError": "Failed to create category",
|
"categoryCreateError": "Failed to create category",
|
||||||
|
"categoryExists": "Category \"{{name}}\" already exists",
|
||||||
"categoryDeleted": "Category deleted",
|
"categoryDeleted": "Category deleted",
|
||||||
"categoryDeleteError": "Failed to delete category",
|
"categoryDeleteError": "Failed to delete category",
|
||||||
"categoryEmpty": "No categories defined yet.",
|
"categoryEmpty": "No categories defined yet.",
|
||||||
|
|||||||
@@ -17,12 +17,13 @@ import { fetchContract, fetchPaperlessDocument } from "../api/contracts";
|
|||||||
import { fetchServerConfig, ServerConfig } from "../api/config";
|
import { fetchServerConfig, ServerConfig } from "../api/config";
|
||||||
import PageHeader from "../components/PageHeader";
|
import PageHeader from "../components/PageHeader";
|
||||||
import { formatCurrency, formatDate } from "../utils/date";
|
import { formatCurrency, formatDate } from "../utils/date";
|
||||||
|
import { translateCategoryName } from "../utils/categories";
|
||||||
|
|
||||||
export default function ContractDetail() {
|
export default function ContractDetail() {
|
||||||
const { contractId } = useParams<{ contractId: string }>();
|
const { contractId } = useParams<{ contractId: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const id = Number(contractId);
|
const id = Number(contractId);
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
|
||||||
const { data: contract, isLoading } = useQuery({
|
const { data: contract, isLoading } = useQuery({
|
||||||
queryKey: ["contracts", id],
|
queryKey: ["contracts", id],
|
||||||
@@ -56,6 +57,9 @@ export default function ContractDetail() {
|
|||||||
? t("contractForm.fields.autoRenew")
|
? t("contractForm.fields.autoRenew")
|
||||||
: "–";
|
: "–";
|
||||||
const notesValue = contract?.notes ?? t("contractDetail.noNotes");
|
const notesValue = contract?.notes ?? t("contractDetail.noNotes");
|
||||||
|
const categoryValue = contract?.category
|
||||||
|
? translateCategoryName(contract.category, i18n.language) || contract.category
|
||||||
|
: "–";
|
||||||
|
|
||||||
if (!Number.isFinite(id)) {
|
if (!Number.isFinite(id)) {
|
||||||
return <Typography>{t("contractForm.loadError")}</Typography>;
|
return <Typography>{t("contractForm.loadError")}</Typography>;
|
||||||
@@ -89,7 +93,7 @@ export default function ContractDetail() {
|
|||||||
<Detail label={t("contractDetail.notice")} value={terminationValue} />
|
<Detail label={t("contractDetail.notice")} value={terminationValue} />
|
||||||
<Detail label={t("contractDetail.renewal")} value={renewalValue} />
|
<Detail label={t("contractDetail.renewal")} value={renewalValue} />
|
||||||
<Detail label={t("contractDetail.price")} value={formatCurrency(contract.price, contract.currency ?? "EUR")} />
|
<Detail label={t("contractDetail.price")} value={formatCurrency(contract.price, contract.currency ?? "EUR")} />
|
||||||
<Detail label={t("contractDetail.category")} value={contract.category ?? "–"} />
|
<Detail label={t("contractDetail.category")} value={categoryValue} />
|
||||||
<Detail label={t("contractDetail.notes")} value={notesValue} />
|
<Detail label={t("contractDetail.notes")} value={notesValue} />
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
} from "../api/contracts";
|
} from "../api/contracts";
|
||||||
import { fetchServerConfig } from "../api/config";
|
import { fetchServerConfig } from "../api/config";
|
||||||
import { createCategory as apiCreateCategory, fetchCategories } from "../api/categories";
|
import { createCategory as apiCreateCategory, fetchCategories } from "../api/categories";
|
||||||
|
import { getCanonicalDefaultCategoryName, translateCategoryName } from "../utils/categories";
|
||||||
import PaperlessSearchDialog from "../components/PaperlessSearchDialog";
|
import PaperlessSearchDialog from "../components/PaperlessSearchDialog";
|
||||||
import PageHeader from "../components/PageHeader";
|
import PageHeader from "../components/PageHeader";
|
||||||
import { useSnackbar } from "../hooks/useSnackbar";
|
import { useSnackbar } from "../hooks/useSnackbar";
|
||||||
@@ -94,7 +95,7 @@ export default function ContractForm({ mode }: Props) {
|
|||||||
const id = contractId ? Number(contractId) : null;
|
const id = contractId ? Number(contractId) : null;
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { showMessage } = useSnackbar();
|
const { showMessage } = useSnackbar();
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
control,
|
control,
|
||||||
@@ -160,7 +161,8 @@ export default function ContractForm({ mode }: Props) {
|
|||||||
await refetchCategories();
|
await refetchCategories();
|
||||||
queryClient.invalidateQueries({ queryKey: ["categories"] });
|
queryClient.invalidateQueries({ queryKey: ["categories"] });
|
||||||
setValue("category", category.name, { shouldDirty: true });
|
setValue("category", category.name, { shouldDirty: true });
|
||||||
showMessage(t("contracts.categoryCreated", { name: category.name }), "success");
|
const displayName = translateCategoryName(category.name, i18n.language) || category.name;
|
||||||
|
showMessage(t("contracts.categoryCreated", { name: displayName }), "success");
|
||||||
setCategoryDialogOpen(false);
|
setCategoryDialogOpen(false);
|
||||||
setNewCategoryName("");
|
setNewCategoryName("");
|
||||||
},
|
},
|
||||||
@@ -171,6 +173,34 @@ export default function ContractForm({ mode }: Props) {
|
|||||||
|
|
||||||
const [categoryDialogOpen, setCategoryDialogOpen] = useState(false);
|
const [categoryDialogOpen, setCategoryDialogOpen] = useState(false);
|
||||||
const [newCategoryName, setNewCategoryName] = useState("");
|
const [newCategoryName, setNewCategoryName] = useState("");
|
||||||
|
const trimmedDialogCategoryName = newCategoryName.trim();
|
||||||
|
const dialogCategoryExists = useMemo(() => {
|
||||||
|
if (!trimmedDialogCategoryName) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!categories) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const normalizedInput = trimmedDialogCategoryName.toLowerCase();
|
||||||
|
const canonicalInput = getCanonicalDefaultCategoryName(trimmedDialogCategoryName)?.toLowerCase() ?? null;
|
||||||
|
return categories.some((category) => {
|
||||||
|
const normalizedCategory = category.name.toLowerCase();
|
||||||
|
return normalizedCategory === normalizedInput || (canonicalInput ? normalizedCategory === canonicalInput : false);
|
||||||
|
});
|
||||||
|
}, [categories, trimmedDialogCategoryName]);
|
||||||
|
|
||||||
|
const handleDialogCreateCategory = () => {
|
||||||
|
if (!trimmedDialogCategoryName) {
|
||||||
|
showMessage(t("contracts.categoryNameRequired"), "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (dialogCategoryExists) {
|
||||||
|
const existingDisplayName = translateCategoryName(trimmedDialogCategoryName, i18n.language) || trimmedDialogCategoryName;
|
||||||
|
showMessage(t("contracts.categoryExists", { name: existingDisplayName }), "info");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createCategoryMutation.mutate(trimmedDialogCategoryName);
|
||||||
|
};
|
||||||
|
|
||||||
const providerSuggestion = useMemo(
|
const providerSuggestion = useMemo(
|
||||||
() => (selectedDocument ? extractPaperlessProvider(selectedDocument) : null),
|
() => (selectedDocument ? extractPaperlessProvider(selectedDocument) : null),
|
||||||
@@ -342,7 +372,15 @@ export default function ContractForm({ mode }: Props) {
|
|||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
const options = categories ?? [];
|
const options = categories ?? [];
|
||||||
const value = field.value ?? "";
|
const value = field.value ?? "";
|
||||||
const hasCurrentValue = Boolean(value) && options.some((category) => category.name.toLowerCase() === String(value).toLowerCase());
|
const normalizedValue = String(value).toLowerCase();
|
||||||
|
const canonicalValue = value ? getCanonicalDefaultCategoryName(String(value))?.toLowerCase() ?? null : null;
|
||||||
|
const hasCurrentValue = Boolean(value) && options.some((category) => {
|
||||||
|
const normalizedCategory = category.name.toLowerCase();
|
||||||
|
if (normalizedCategory === normalizedValue) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return canonicalValue ? normalizedCategory === canonicalValue : false;
|
||||||
|
});
|
||||||
return (
|
return (
|
||||||
<TextField
|
<TextField
|
||||||
label={t("contractForm.fields.category")}
|
label={t("contractForm.fields.category")}
|
||||||
@@ -362,6 +400,7 @@ export default function ContractForm({ mode }: Props) {
|
|||||||
field.onChange(nextValue);
|
field.onChange(nextValue);
|
||||||
}}
|
}}
|
||||||
SelectProps={{ displayEmpty: true }}
|
SelectProps={{ displayEmpty: true }}
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
disabled={loadingCategories}
|
disabled={loadingCategories}
|
||||||
error={Boolean(categoriesError)}
|
error={Boolean(categoriesError)}
|
||||||
helperText={categoriesError ? t("contracts.categoryLoadError") : undefined}
|
helperText={categoriesError ? t("contracts.categoryLoadError") : undefined}
|
||||||
@@ -380,13 +419,13 @@ export default function ContractForm({ mode }: Props) {
|
|||||||
) : (
|
) : (
|
||||||
options.map((category) => (
|
options.map((category) => (
|
||||||
<MenuItem key={category.id} value={category.name}>
|
<MenuItem key={category.id} value={category.name}>
|
||||||
{category.name}
|
{translateCategoryName(category.name, i18n.language) || category.name}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
{!loadingCategories && !categoriesError && value && !hasCurrentValue && (
|
{!loadingCategories && !categoriesError && value && !hasCurrentValue && (
|
||||||
<MenuItem value={value as string}>
|
<MenuItem value={value as string}>
|
||||||
{String(value)}
|
{translateCategoryName(String(value), i18n.language) || String(value)}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)}
|
)}
|
||||||
<MenuItem value="__add__">{t("contracts.categoryAdd")}</MenuItem>
|
<MenuItem value="__add__">{t("contracts.categoryAdd")}</MenuItem>
|
||||||
@@ -559,6 +598,12 @@ export default function ContractForm({ mode }: Props) {
|
|||||||
label={t("contracts.categoryNameLabel")}
|
label={t("contracts.categoryNameLabel")}
|
||||||
value={newCategoryName}
|
value={newCategoryName}
|
||||||
onChange={(event) => setNewCategoryName(event.target.value)}
|
onChange={(event) => setNewCategoryName(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
handleDialogCreateCategory();
|
||||||
|
}
|
||||||
|
}}
|
||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
@@ -570,15 +615,8 @@ export default function ContractForm({ mode }: Props) {
|
|||||||
{t("actions.cancel")}
|
{t("actions.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={handleDialogCreateCategory}
|
||||||
const trimmed = newCategoryName.trim();
|
disabled={createCategoryMutation.isPending || !trimmedDialogCategoryName}
|
||||||
if (!trimmed) {
|
|
||||||
showMessage(t("contracts.categoryNameRequired"), "warning");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
createCategoryMutation.mutate(trimmed);
|
|
||||||
}}
|
|
||||||
disabled={createCategoryMutation.isPending || !newCategoryName.trim()}
|
|
||||||
variant="contained"
|
variant="contained"
|
||||||
>
|
>
|
||||||
{t("contracts.categoryCreate")}
|
{t("contracts.categoryCreate")}
|
||||||
|
|||||||
@@ -34,12 +34,13 @@ import PageHeader from "../components/PageHeader";
|
|||||||
import { useSnackbar } from "../hooks/useSnackbar";
|
import { useSnackbar } from "../hooks/useSnackbar";
|
||||||
import { Contract } from "../types";
|
import { Contract } from "../types";
|
||||||
import { formatCurrency, formatDate } from "../utils/date";
|
import { formatCurrency, formatDate } from "../utils/date";
|
||||||
|
import { translateCategoryName } from "../utils/categories";
|
||||||
|
|
||||||
export default function ContractsList() {
|
export default function ContractsList() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { showMessage } = useSnackbar();
|
const { showMessage } = useSnackbar();
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const [contractToDelete, setContractToDelete] = useState<Contract | null>(null);
|
const [contractToDelete, setContractToDelete] = useState<Contract | null>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -72,7 +73,7 @@ export default function ContractsList() {
|
|||||||
return normalizedContracts.filter((contract) => {
|
return normalizedContracts.filter((contract) => {
|
||||||
const searchMatch =
|
const searchMatch =
|
||||||
!search ||
|
!search ||
|
||||||
[contract.title, contract.provider, contract.notes, contract.category]
|
[contract.title, contract.provider, contract.notes, contract.category, translateCategoryName(contract.category ?? "", i18n.language)]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.some((field) => field!.toLowerCase().includes(search.toLowerCase()));
|
.some((field) => field!.toLowerCase().includes(search.toLowerCase()));
|
||||||
|
|
||||||
@@ -130,7 +131,7 @@ export default function ContractsList() {
|
|||||||
<MenuItem value="all">{t("contracts.filterAll")}</MenuItem>
|
<MenuItem value="all">{t("contracts.filterAll")}</MenuItem>
|
||||||
{(categoryOptions ?? []).map((item) => (
|
{(categoryOptions ?? []).map((item) => (
|
||||||
<MenuItem key={item.id} value={item.name}>
|
<MenuItem key={item.id} value={item.name}>
|
||||||
{item.name}
|
{translateCategoryName(item.name, i18n.language) || item.name}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</TextField>
|
</TextField>
|
||||||
@@ -190,7 +191,11 @@ export default function ContractsList() {
|
|||||||
</Typography>
|
</Typography>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{contract.provider ?? "–"}</TableCell>
|
<TableCell>{contract.provider ?? "–"}</TableCell>
|
||||||
<TableCell>{contract.category ?? "–"}</TableCell>
|
<TableCell>
|
||||||
|
{contract.category
|
||||||
|
? translateCategoryName(contract.category, i18n.language) || contract.category
|
||||||
|
: "–"}
|
||||||
|
</TableCell>
|
||||||
<TableCell>{formatCurrency(contract.price, contract.currency ?? "EUR")}</TableCell>
|
<TableCell>{formatCurrency(contract.price, contract.currency ?? "EUR")}</TableCell>
|
||||||
<TableCell>{formatDate(contract.contractEndDate)}</TableCell>
|
<TableCell>{formatDate(contract.contractEndDate)}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
Grid,
|
Grid,
|
||||||
IconButton,
|
IconButton,
|
||||||
InputAdornment,
|
InputAdornment,
|
||||||
|
MenuItem,
|
||||||
List,
|
List,
|
||||||
ListItem,
|
ListItem,
|
||||||
ListItemAvatar,
|
ListItemAvatar,
|
||||||
@@ -27,7 +28,7 @@ import {
|
|||||||
Typography
|
Typography
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -47,6 +48,7 @@ import PageHeader from "../components/PageHeader";
|
|||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
import { useSnackbar } from "../hooks/useSnackbar";
|
import { useSnackbar } from "../hooks/useSnackbar";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { getCanonicalDefaultCategoryName, translateCategoryName } from "../utils/categories";
|
||||||
|
|
||||||
interface HealthResponse {
|
interface HealthResponse {
|
||||||
status: string;
|
status: string;
|
||||||
@@ -55,6 +57,8 @@ interface HealthResponse {
|
|||||||
type FormValues = {
|
type FormValues = {
|
||||||
paperlessBaseUrl: string;
|
paperlessBaseUrl: string;
|
||||||
paperlessExternalUrl: string;
|
paperlessExternalUrl: string;
|
||||||
|
appExternalUrl: string;
|
||||||
|
appLocale: string;
|
||||||
paperlessToken: string;
|
paperlessToken: string;
|
||||||
schedulerIntervalMinutes: number;
|
schedulerIntervalMinutes: number;
|
||||||
alertDaysBefore: number;
|
alertDaysBefore: number;
|
||||||
@@ -76,6 +80,8 @@ type FormValues = {
|
|||||||
const defaultValues: FormValues = {
|
const defaultValues: FormValues = {
|
||||||
paperlessBaseUrl: "",
|
paperlessBaseUrl: "",
|
||||||
paperlessExternalUrl: "",
|
paperlessExternalUrl: "",
|
||||||
|
appExternalUrl: "",
|
||||||
|
appLocale: "de",
|
||||||
paperlessToken: "",
|
paperlessToken: "",
|
||||||
schedulerIntervalMinutes: 60,
|
schedulerIntervalMinutes: 60,
|
||||||
alertDaysBefore: 30,
|
alertDaysBefore: 30,
|
||||||
@@ -97,7 +103,7 @@ const defaultValues: FormValues = {
|
|||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const { authEnabled } = useAuth();
|
const { authEnabled } = useAuth();
|
||||||
const { showMessage } = useSnackbar();
|
const { showMessage } = useSnackbar();
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
@@ -159,6 +165,8 @@ export default function SettingsPage() {
|
|||||||
const values: FormValues = {
|
const values: FormValues = {
|
||||||
paperlessBaseUrl: settingsData.values.paperlessBaseUrl ?? "",
|
paperlessBaseUrl: settingsData.values.paperlessBaseUrl ?? "",
|
||||||
paperlessExternalUrl: settingsData.values.paperlessExternalUrl ?? "",
|
paperlessExternalUrl: settingsData.values.paperlessExternalUrl ?? "",
|
||||||
|
appExternalUrl: settingsData.values.appExternalUrl ?? "",
|
||||||
|
appLocale: settingsData.values.appLocale ?? "de",
|
||||||
paperlessToken: "",
|
paperlessToken: "",
|
||||||
schedulerIntervalMinutes: settingsData.values.schedulerIntervalMinutes,
|
schedulerIntervalMinutes: settingsData.values.schedulerIntervalMinutes,
|
||||||
alertDaysBefore: settingsData.values.alertDaysBefore,
|
alertDaysBefore: settingsData.values.alertDaysBefore,
|
||||||
@@ -223,7 +231,8 @@ export default function SettingsPage() {
|
|||||||
await refetchCategories();
|
await refetchCategories();
|
||||||
queryClient.invalidateQueries({ queryKey: ["categories"] });
|
queryClient.invalidateQueries({ queryKey: ["categories"] });
|
||||||
setNewCategoryName("");
|
setNewCategoryName("");
|
||||||
showMessage(t("settings.categoryCreated", { name: category.name }), "success");
|
const displayName = translateCategoryName(category.name, i18n.language) || category.name;
|
||||||
|
showMessage(t("settings.categoryCreated", { name: displayName }), "success");
|
||||||
},
|
},
|
||||||
onError: (error: Error) => showMessage(error.message ?? t("settings.categoryCreateError"), "error")
|
onError: (error: Error) => showMessage(error.message ?? t("settings.categoryCreateError"), "error")
|
||||||
});
|
});
|
||||||
@@ -238,6 +247,38 @@ export default function SettingsPage() {
|
|||||||
onError: (error: Error) => showMessage(error.message ?? t("settings.categoryDeleteError"), "error")
|
onError: (error: Error) => showMessage(error.message ?? t("settings.categoryDeleteError"), "error")
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const trimmedCategoryName = newCategoryName.trim();
|
||||||
|
const categoryExists = useMemo(() => {
|
||||||
|
if (!trimmedCategoryName) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!categories) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const normalizedInput = trimmedCategoryName.toLowerCase();
|
||||||
|
const canonicalInput = getCanonicalDefaultCategoryName(trimmedCategoryName)?.toLowerCase() ?? null;
|
||||||
|
return categories.some((category) => {
|
||||||
|
const normalizedName = category.name.toLowerCase();
|
||||||
|
return (
|
||||||
|
normalizedName === normalizedInput ||
|
||||||
|
(canonicalInput ? normalizedName === canonicalInput : false)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, [categories, trimmedCategoryName]);
|
||||||
|
|
||||||
|
const handleAddCategory = useCallback(() => {
|
||||||
|
if (!trimmedCategoryName) {
|
||||||
|
showMessage(t("settings.categoryNameRequired"), "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (categoryExists) {
|
||||||
|
const existingDisplayName = translateCategoryName(trimmedCategoryName, i18n.language) || trimmedCategoryName;
|
||||||
|
showMessage(t("settings.categoryExists", { name: existingDisplayName }), "info");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createCategoryMutation.mutate(trimmedCategoryName);
|
||||||
|
}, [categoryExists, createCategoryMutation, i18n.language, showMessage, t, trimmedCategoryName]);
|
||||||
|
|
||||||
const settings = settingsData;
|
const settings = settingsData;
|
||||||
const icalSecret = settings?.icalSecret ?? null;
|
const icalSecret = settings?.icalSecret ?? null;
|
||||||
const icalUrl = useMemo(() => {
|
const icalUrl = useMemo(() => {
|
||||||
@@ -265,6 +306,12 @@ export default function SettingsPage() {
|
|||||||
if (formValues.paperlessExternalUrl !== initial.paperlessExternalUrl) {
|
if (formValues.paperlessExternalUrl !== initial.paperlessExternalUrl) {
|
||||||
payload.paperlessExternalUrl = trimOrNull(formValues.paperlessExternalUrl);
|
payload.paperlessExternalUrl = trimOrNull(formValues.paperlessExternalUrl);
|
||||||
}
|
}
|
||||||
|
if (formValues.appExternalUrl !== initial.appExternalUrl) {
|
||||||
|
payload.appExternalUrl = trimOrNull(formValues.appExternalUrl);
|
||||||
|
}
|
||||||
|
if (formValues.appLocale !== initial.appLocale) {
|
||||||
|
payload.appLocale = formValues.appLocale;
|
||||||
|
}
|
||||||
|
|
||||||
if (removePaperlessToken) {
|
if (removePaperlessToken) {
|
||||||
payload.paperlessToken = null;
|
payload.paperlessToken = null;
|
||||||
@@ -521,19 +568,18 @@ export default function SettingsPage() {
|
|||||||
label={t("settings.categoryName")}
|
label={t("settings.categoryName")}
|
||||||
value={newCategoryName}
|
value={newCategoryName}
|
||||||
onChange={(event) => setNewCategoryName(event.target.value)}
|
onChange={(event) => setNewCategoryName(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
handleAddCategory();
|
||||||
|
}
|
||||||
|
}}
|
||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
onClick={() => {
|
onClick={handleAddCategory}
|
||||||
const trimmed = newCategoryName.trim();
|
disabled={createCategoryMutation.isPending || !trimmedCategoryName}
|
||||||
if (!trimmed) {
|
|
||||||
showMessage(t("settings.categoryNameRequired"), "warning");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
createCategoryMutation.mutate(trimmed);
|
|
||||||
}}
|
|
||||||
disabled={createCategoryMutation.isPending || !newCategoryName.trim()}
|
|
||||||
>
|
>
|
||||||
{t("settings.categoryAdd")}
|
{t("settings.categoryAdd")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -543,7 +589,7 @@ export default function SettingsPage() {
|
|||||||
{categories.map((category) => (
|
{categories.map((category) => (
|
||||||
<Chip
|
<Chip
|
||||||
key={category.id}
|
key={category.id}
|
||||||
label={category.name}
|
label={translateCategoryName(category.name, i18n.language) || category.name}
|
||||||
onDelete={() => deleteCategoryMutation.mutate(category.id)}
|
onDelete={() => deleteCategoryMutation.mutate(category.id)}
|
||||||
disabled={deleteCategoryMutation.isPending}
|
disabled={deleteCategoryMutation.isPending}
|
||||||
/>
|
/>
|
||||||
@@ -579,6 +625,23 @@ export default function SettingsPage() {
|
|||||||
placeholder={t("settings.paperlessExample")}
|
placeholder={t("settings.paperlessExample")}
|
||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
|
<TextField
|
||||||
|
label={t("settings.appExternalUrl")}
|
||||||
|
{...register("appExternalUrl")}
|
||||||
|
placeholder={t("settings.appExternalExample")}
|
||||||
|
fullWidth
|
||||||
|
helperText={t("settings.appExternalUrlHelp")}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label={t("settings.appLocaleLabel")}
|
||||||
|
{...register("appLocale")}
|
||||||
|
fullWidth
|
||||||
|
helperText={t("settings.appLocaleHelp")}
|
||||||
|
>
|
||||||
|
<MenuItem value="de">{t("settings.appLocaleGerman")}</MenuItem>
|
||||||
|
<MenuItem value="en">{t("settings.appLocaleEnglish")}</MenuItem>
|
||||||
|
</TextField>
|
||||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems={{ xs: "stretch", sm: "center" }}>
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems={{ xs: "stretch", sm: "center" }}>
|
||||||
<TextField
|
<TextField
|
||||||
label={paperlessTokenSet && !removePaperlessToken ? t("settings.paperlessTokenNew") : t("settings.paperlessToken")}
|
label={paperlessTokenSet && !removePaperlessToken ? t("settings.paperlessTokenNew") : t("settings.paperlessToken")}
|
||||||
|
|||||||
10
frontend/src/types/shared.d.ts
vendored
Normal file
10
frontend/src/types/shared.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
declare module "@shared/defaultCategories.json" {
|
||||||
|
interface DefaultCategory {
|
||||||
|
key: string;
|
||||||
|
de: string;
|
||||||
|
en: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const categories: DefaultCategory[];
|
||||||
|
export default categories;
|
||||||
|
}
|
||||||
45
frontend/src/utils/categories.ts
Normal file
45
frontend/src/utils/categories.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import defaultCategories from "@shared/defaultCategories.json";
|
||||||
|
|
||||||
|
type DefaultCategory = {
|
||||||
|
key: string;
|
||||||
|
de: string;
|
||||||
|
en: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_CATEGORY_DATA = defaultCategories as DefaultCategory[];
|
||||||
|
|
||||||
|
function normalize(input: string): string {
|
||||||
|
return input.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
const translationsByNormalizedName = new Map<string, DefaultCategory>();
|
||||||
|
for (const category of DEFAULT_CATEGORY_DATA) {
|
||||||
|
translationsByNormalizedName.set(normalize(category.de), category);
|
||||||
|
translationsByNormalizedName.set(normalize(category.en), category);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCanonicalDefaultCategoryName(input: string): string | null {
|
||||||
|
const normalized = normalize(input);
|
||||||
|
const match = translationsByNormalizedName.get(normalized);
|
||||||
|
return match ? match.de : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function translateCategoryName(name: string | null | undefined, language: string): string {
|
||||||
|
if (!name) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const normalized = normalize(name);
|
||||||
|
const match = translationsByNormalizedName.get(normalized);
|
||||||
|
if (!match) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (language.startsWith("de")) {
|
||||||
|
return match.de;
|
||||||
|
}
|
||||||
|
if (language.startsWith("en")) {
|
||||||
|
return match.en;
|
||||||
|
}
|
||||||
|
// For other languages we fall back to English to keep labels readable.
|
||||||
|
return match.en;
|
||||||
|
}
|
||||||
@@ -15,8 +15,12 @@
|
|||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"types": ["vite/client"]
|
"types": ["vite/client"],
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@shared/*": ["../shared/*"]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src", "../shared/**/*.json"],
|
||||||
"references": [{ "path": "./tsconfig.node.json" }]
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
|
import { fileURLToPath, URL } from "node:url";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@shared": fileURLToPath(new URL("../shared", import.meta.url))
|
||||||
|
}
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
|
fs: {
|
||||||
|
allow: [fileURLToPath(new URL("..", import.meta.url))]
|
||||||
|
},
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": {
|
"/api": {
|
||||||
target: "http://localhost:8000",
|
target: "http://localhost:8000",
|
||||||
|
|||||||
37
shared/defaultCategories.json
Normal file
37
shared/defaultCategories.json
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"key": "insurance",
|
||||||
|
"de": "Versicherung",
|
||||||
|
"en": "Insurance"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "energy",
|
||||||
|
"de": "Strom & Energie",
|
||||||
|
"en": "Energy & Utilities"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "internet",
|
||||||
|
"de": "Internet & Telefon",
|
||||||
|
"en": "Internet & Phone"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "housing",
|
||||||
|
"de": "Miete & Wohnen",
|
||||||
|
"en": "Rent & Housing"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "mobile",
|
||||||
|
"de": "Mobilfunk",
|
||||||
|
"en": "Mobile Contracts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "media",
|
||||||
|
"de": "Streaming & Medien",
|
||||||
|
"en": "Streaming & Media"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "service",
|
||||||
|
"de": "Wartung & Service",
|
||||||
|
"en": "Maintenance & Service"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import db from "./db.js";
|
import db from "./db.js";
|
||||||
|
import { DEFAULT_CATEGORY_NAMES, getCanonicalDefaultCategoryName } from "./categoryDefaults.js";
|
||||||
|
|
||||||
export interface Category {
|
export interface Category {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -22,6 +23,16 @@ DELETE FROM categories
|
|||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
const countStmt = db.prepare(`
|
||||||
|
SELECT COUNT(*) as count
|
||||||
|
FROM categories
|
||||||
|
`);
|
||||||
|
|
||||||
|
const seedInsertStmt = db.prepare(`
|
||||||
|
INSERT OR IGNORE INTO categories (name)
|
||||||
|
VALUES (?)
|
||||||
|
`);
|
||||||
|
|
||||||
const findByNameStmt = db.prepare(`
|
const findByNameStmt = db.prepare(`
|
||||||
SELECT id, name, created_at
|
SELECT id, name, created_at
|
||||||
FROM categories
|
FROM categories
|
||||||
@@ -34,13 +45,33 @@ FROM categories
|
|||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
function seedDefaultCategoriesIfEmpty() {
|
||||||
|
const row = countStmt.get() as { count: number } | undefined;
|
||||||
|
const count = row?.count ?? 0;
|
||||||
|
if (count === 0) {
|
||||||
|
const insertDefaults = db.transaction((names: string[]) => {
|
||||||
|
for (const name of names) {
|
||||||
|
seedInsertStmt.run(name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
insertDefaults(DEFAULT_CATEGORY_NAMES);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
seedDefaultCategoriesIfEmpty();
|
||||||
|
|
||||||
export function findCategoryByName(name: string): Category | null {
|
export function findCategoryByName(name: string): Category | null {
|
||||||
const row = findByNameStmt.get(name.trim());
|
const row = findByNameStmt.get(name.trim());
|
||||||
return row ? mapCategoryRow(row) : null;
|
return row ? mapCategoryRow(row) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listCategories(): Category[] {
|
export function listCategories(): Category[] {
|
||||||
return listStmt.all().map(mapCategoryRow);
|
let rows = listStmt.all();
|
||||||
|
if (rows.length === 0) {
|
||||||
|
seedDefaultCategoriesIfEmpty();
|
||||||
|
rows = listStmt.all();
|
||||||
|
}
|
||||||
|
return rows.map(mapCategoryRow);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createCategory(name: string): Category {
|
export function createCategory(name: string): Category {
|
||||||
@@ -49,9 +80,23 @@ export function createCategory(name: string): Category {
|
|||||||
throw new Error("Category name must not be empty");
|
throw new Error("Category name must not be empty");
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = findByNameStmt.get(trimmed);
|
const existingExact = findByNameStmt.get(trimmed);
|
||||||
if (existing) {
|
if (existingExact) {
|
||||||
return mapCategoryRow(existing);
|
return mapCategoryRow(existingExact);
|
||||||
|
}
|
||||||
|
|
||||||
|
const canonical = getCanonicalDefaultCategoryName(trimmed);
|
||||||
|
if (canonical) {
|
||||||
|
const existingCanonical = findByNameStmt.get(canonical);
|
||||||
|
if (existingCanonical) {
|
||||||
|
return mapCategoryRow(existingCanonical);
|
||||||
|
}
|
||||||
|
const canonicalInfo = insertStmt.run(canonical);
|
||||||
|
const canonicalCreated = getByIdStmt.get(canonicalInfo.lastInsertRowid);
|
||||||
|
if (!canonicalCreated) {
|
||||||
|
throw new Error("Failed to create category");
|
||||||
|
}
|
||||||
|
return mapCategoryRow(canonicalCreated);
|
||||||
}
|
}
|
||||||
|
|
||||||
const info = insertStmt.run(trimmed);
|
const info = insertStmt.run(trimmed);
|
||||||
|
|||||||
43
src/categoryDefaults.ts
Normal file
43
src/categoryDefaults.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
type DefaultCategory = {
|
||||||
|
key: string;
|
||||||
|
de: string;
|
||||||
|
en: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentFile = fileURLToPath(import.meta.url);
|
||||||
|
const currentDir = dirname(currentFile);
|
||||||
|
const jsonPath = resolve(currentDir, "../shared/defaultCategories.json");
|
||||||
|
const fileContent = readFileSync(jsonPath, "utf-8");
|
||||||
|
const DEFAULT_CATEGORY_DATA = JSON.parse(fileContent) as DefaultCategory[];
|
||||||
|
|
||||||
|
function normalize(input: string): string {
|
||||||
|
return input.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_CATEGORY_NAMES = DEFAULT_CATEGORY_DATA.map((category) => category.de);
|
||||||
|
|
||||||
|
export function getCanonicalDefaultCategoryName(input: string): string | null {
|
||||||
|
const normalized = normalize(input);
|
||||||
|
if (!normalized) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const match = DEFAULT_CATEGORY_DATA.find((category) => {
|
||||||
|
return (
|
||||||
|
normalize(category.de) === normalized ||
|
||||||
|
normalize(category.en) === normalized
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return match ? match.de : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDefaultCategoryName(input: string): boolean {
|
||||||
|
return getCanonicalDefaultCategoryName(input) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultCategoryTranslations(): DefaultCategory[] {
|
||||||
|
return DEFAULT_CATEGORY_DATA.map((category) => ({ ...category }));
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const supportedLocales = ["de", "en"] as const;
|
||||||
|
|
||||||
const configSchema = z.object({
|
const configSchema = z.object({
|
||||||
port: z.coerce.number().min(1).max(65535).default(8000),
|
port: z.coerce.number().min(1).max(65535).default(8000),
|
||||||
logLevel: z.enum(["debug", "info", "warn", "error"]).default("info"),
|
logLevel: z.enum(["debug", "info", "warn", "error"]).default("info"),
|
||||||
@@ -7,6 +9,8 @@ const configSchema = z.object({
|
|||||||
paperlessBaseUrl: z.string().url().optional(),
|
paperlessBaseUrl: z.string().url().optional(),
|
||||||
paperlessToken: z.string().min(1).optional(),
|
paperlessToken: z.string().min(1).optional(),
|
||||||
paperlessExternalUrl: z.string().url().optional(),
|
paperlessExternalUrl: z.string().url().optional(),
|
||||||
|
appExternalUrl: z.string().url().optional(),
|
||||||
|
appLocale: z.enum(supportedLocales).default("de"),
|
||||||
schedulerIntervalMinutes: z.coerce.number().min(5).default(60),
|
schedulerIntervalMinutes: z.coerce.number().min(5).default(60),
|
||||||
alertDaysBefore: z.coerce.number().min(1).default(30),
|
alertDaysBefore: z.coerce.number().min(1).default(30),
|
||||||
mailServer: z.string().optional(),
|
mailServer: z.string().optional(),
|
||||||
@@ -41,6 +45,8 @@ const rawConfig = {
|
|||||||
paperlessBaseUrl: process.env.PAPERLESS_BASE_URL,
|
paperlessBaseUrl: process.env.PAPERLESS_BASE_URL,
|
||||||
paperlessToken: process.env.PAPERLESS_TOKEN,
|
paperlessToken: process.env.PAPERLESS_TOKEN,
|
||||||
paperlessExternalUrl: process.env.PAPERLESS_EXTERNAL_URL,
|
paperlessExternalUrl: process.env.PAPERLESS_EXTERNAL_URL,
|
||||||
|
appExternalUrl: process.env.APP_EXTERNAL_URL,
|
||||||
|
appLocale: process.env.APP_LOCALE,
|
||||||
schedulerIntervalMinutes: process.env.SCHEDULER_INTERVAL_MINUTES,
|
schedulerIntervalMinutes: process.env.SCHEDULER_INTERVAL_MINUTES,
|
||||||
alertDaysBefore: process.env.ALERT_DAYS_BEFORE,
|
alertDaysBefore: process.env.ALERT_DAYS_BEFORE,
|
||||||
mailServer: process.env.MAIL_SERVER,
|
mailServer: process.env.MAIL_SERVER,
|
||||||
|
|||||||
13
src/db.ts
13
src/db.ts
@@ -4,6 +4,7 @@ import { dirname, resolve } from "node:path";
|
|||||||
|
|
||||||
import { config } from "./config.js";
|
import { config } from "./config.js";
|
||||||
import { createLogger } from "./logger.js";
|
import { createLogger } from "./logger.js";
|
||||||
|
import { DEFAULT_CATEGORY_NAMES } from "./categoryDefaults.js";
|
||||||
|
|
||||||
const logger = createLogger(config.logLevel);
|
const logger = createLogger(config.logLevel);
|
||||||
|
|
||||||
@@ -52,21 +53,11 @@ CREATE TABLE IF NOT EXISTS categories (
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
|
|
||||||
const defaultCategories = [
|
|
||||||
"Versicherung",
|
|
||||||
"Strom & Energie",
|
|
||||||
"Internet & Telefon",
|
|
||||||
"Miete & Wohnen",
|
|
||||||
"Mobilfunk",
|
|
||||||
"Streaming & Medien",
|
|
||||||
"Wartung & Service"
|
|
||||||
];
|
|
||||||
|
|
||||||
const categoryRow = db.prepare(`SELECT COUNT(*) as count FROM categories`).get() as { count: number } | undefined;
|
const categoryRow = db.prepare(`SELECT COUNT(*) as count FROM categories`).get() as { count: number } | undefined;
|
||||||
const categoryCount = categoryRow?.count ?? 0;
|
const categoryCount = categoryRow?.count ?? 0;
|
||||||
if (categoryCount === 0) {
|
if (categoryCount === 0) {
|
||||||
const insertStmt = db.prepare(`INSERT OR IGNORE INTO categories (name) VALUES (?)`);
|
const insertStmt = db.prepare(`INSERT OR IGNORE INTO categories (name) VALUES (?)`);
|
||||||
for (const name of defaultCategories) {
|
for (const name of DEFAULT_CATEGORY_NAMES) {
|
||||||
insertStmt.run(name);
|
insertStmt.run(name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
25
src/index.ts
25
src/index.ts
@@ -24,7 +24,7 @@ import {
|
|||||||
import type { RuntimeSettings } from "./runtimeSettings.js";
|
import type { RuntimeSettings } from "./runtimeSettings.js";
|
||||||
import type { UpcomingDeadline } from "./types.js";
|
import type { UpcomingDeadline } from "./types.js";
|
||||||
import { ContractPayload } from "./types.js";
|
import { ContractPayload } from "./types.js";
|
||||||
import { sendDeadlineNotifications, sendTestEmail, sendTestNtfy } from "./notifications.js";
|
import { sendTestEmail, sendTestNtfy } from "./notifications.js";
|
||||||
import { contractCreateSchema, contractUpdateSchema } from "./validators.js";
|
import { contractCreateSchema, contractUpdateSchema } from "./validators.js";
|
||||||
import { formatDateAsICS } from "./utils.js";
|
import { formatDateAsICS } from "./utils.js";
|
||||||
|
|
||||||
@@ -34,6 +34,13 @@ function buildBaseAppUrl(req: Request): string {
|
|||||||
return `${forwardedProto}://${forwardedHost}`.replace(/\/$/, "");
|
return `${forwardedProto}://${forwardedHost}`.replace(/\/$/, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveAppBaseUrl(req: Request, runtime: RuntimeSettings): string {
|
||||||
|
if (runtime.appExternalUrl) {
|
||||||
|
return runtime.appExternalUrl.replace(/\/$/, "");
|
||||||
|
}
|
||||||
|
return buildBaseAppUrl(req);
|
||||||
|
}
|
||||||
|
|
||||||
const logger = createLogger(config.logLevel);
|
const logger = createLogger(config.logLevel);
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
@@ -64,6 +71,8 @@ const loginSchema = z.object({
|
|||||||
const settingsUpdateSchema = z.object({
|
const settingsUpdateSchema = z.object({
|
||||||
paperlessBaseUrl: z.string().url().nullable().optional(),
|
paperlessBaseUrl: z.string().url().nullable().optional(),
|
||||||
paperlessExternalUrl: z.string().url().nullable().optional(),
|
paperlessExternalUrl: z.string().url().nullable().optional(),
|
||||||
|
appExternalUrl: z.string().url().nullable().optional(),
|
||||||
|
appLocale: z.enum(["de", "en"]).optional(),
|
||||||
paperlessToken: z.string().min(1).nullable().optional(),
|
paperlessToken: z.string().min(1).nullable().optional(),
|
||||||
schedulerIntervalMinutes: z.coerce.number().min(5).max(1440).optional(),
|
schedulerIntervalMinutes: z.coerce.number().min(5).max(1440).optional(),
|
||||||
alertDaysBefore: z.coerce.number().min(1).max(365).optional(),
|
alertDaysBefore: z.coerce.number().min(1).max(365).optional(),
|
||||||
@@ -92,6 +101,8 @@ function formatSettingsResponse(runtime: RuntimeSettings) {
|
|||||||
values: {
|
values: {
|
||||||
paperlessBaseUrl: runtime.paperlessBaseUrl,
|
paperlessBaseUrl: runtime.paperlessBaseUrl,
|
||||||
paperlessExternalUrl: runtime.paperlessExternalUrl,
|
paperlessExternalUrl: runtime.paperlessExternalUrl,
|
||||||
|
appExternalUrl: runtime.appExternalUrl,
|
||||||
|
appLocale: runtime.appLocale,
|
||||||
schedulerIntervalMinutes: runtime.schedulerIntervalMinutes,
|
schedulerIntervalMinutes: runtime.schedulerIntervalMinutes,
|
||||||
alertDaysBefore: runtime.alertDaysBefore,
|
alertDaysBefore: runtime.alertDaysBefore,
|
||||||
mailServer: runtime.mailServer,
|
mailServer: runtime.mailServer,
|
||||||
@@ -237,7 +248,7 @@ app.get("/calendar/feed.ics", (req, res) => {
|
|||||||
const runtime = { ...getRuntimeSettings(), icalSecret: secret };
|
const runtime = { ...getRuntimeSettings(), icalSecret: secret };
|
||||||
const deadlines = listUpcomingDeadlines(365);
|
const deadlines = listUpcomingDeadlines(365);
|
||||||
const paperlessUrl = runtime.paperlessExternalUrl ?? runtime.paperlessBaseUrl ?? null;
|
const paperlessUrl = runtime.paperlessExternalUrl ?? runtime.paperlessBaseUrl ?? null;
|
||||||
const baseAppUrl = buildBaseAppUrl(req);
|
const baseAppUrl = resolveAppBaseUrl(req, runtime);
|
||||||
const ics = buildIcsFeed(deadlines, paperlessUrl, baseAppUrl);
|
const ics = buildIcsFeed(deadlines, paperlessUrl, baseAppUrl);
|
||||||
|
|
||||||
res.setHeader("Content-Type", "text/calendar; charset=utf-8");
|
res.setHeader("Content-Type", "text/calendar; charset=utf-8");
|
||||||
@@ -259,6 +270,8 @@ app.get("/config", (_req, res) => {
|
|||||||
databasePath: config.databasePath,
|
databasePath: config.databasePath,
|
||||||
paperlessBaseUrl: runtime.paperlessBaseUrl,
|
paperlessBaseUrl: runtime.paperlessBaseUrl,
|
||||||
paperlessExternalUrl: runtime.paperlessExternalUrl,
|
paperlessExternalUrl: runtime.paperlessExternalUrl,
|
||||||
|
appExternalUrl: runtime.appExternalUrl,
|
||||||
|
appLocale: runtime.appLocale,
|
||||||
paperlessConfigured,
|
paperlessConfigured,
|
||||||
schedulerIntervalMinutes: runtime.schedulerIntervalMinutes,
|
schedulerIntervalMinutes: runtime.schedulerIntervalMinutes,
|
||||||
alertDaysBefore: runtime.alertDaysBefore,
|
alertDaysBefore: runtime.alertDaysBefore,
|
||||||
@@ -291,6 +304,14 @@ app.put("/settings", (req, res) => {
|
|||||||
if (Object.prototype.hasOwnProperty.call(payload, "paperlessExternalUrl")) {
|
if (Object.prototype.hasOwnProperty.call(payload, "paperlessExternalUrl")) {
|
||||||
update.paperlessExternalUrl = payload.paperlessExternalUrl ?? null;
|
update.paperlessExternalUrl = payload.paperlessExternalUrl ?? null;
|
||||||
}
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(payload, "appExternalUrl")) {
|
||||||
|
update.appExternalUrl = payload.appExternalUrl ?? null;
|
||||||
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(payload, "appLocale")) {
|
||||||
|
if (typeof payload.appLocale === "string") {
|
||||||
|
update.appLocale = payload.appLocale;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (Object.prototype.hasOwnProperty.call(payload, "paperlessToken")) {
|
if (Object.prototype.hasOwnProperty.call(payload, "paperlessToken")) {
|
||||||
update.paperlessToken = payload.paperlessToken ?? null;
|
update.paperlessToken = payload.paperlessToken ?? null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,16 @@ import nodemailer from "nodemailer";
|
|||||||
import { config } from "./config.js";
|
import { config } from "./config.js";
|
||||||
import { createLogger } from "./logger.js";
|
import { createLogger } from "./logger.js";
|
||||||
import type { RuntimeSettings } from "./runtimeSettings.js";
|
import type { RuntimeSettings } from "./runtimeSettings.js";
|
||||||
|
import type { UpcomingDeadline } from "./types.js";
|
||||||
|
|
||||||
const logger = createLogger(config.logLevel);
|
const logger = createLogger(config.logLevel);
|
||||||
|
|
||||||
|
type NotificationContent = {
|
||||||
|
subject: string;
|
||||||
|
body: string;
|
||||||
|
clickUrl?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
async function sendEmail(subject: string, body: string, settings: RuntimeSettings): Promise<void> {
|
async function sendEmail(subject: string, body: string, settings: RuntimeSettings): Promise<void> {
|
||||||
if (!settings.mailServer || !settings.mailFrom || !settings.mailTo) {
|
if (!settings.mailServer || !settings.mailFrom || !settings.mailTo) {
|
||||||
logger.debug("Mail configuration incomplete; skipping email alert.");
|
logger.debug("Mail configuration incomplete; skipping email alert.");
|
||||||
@@ -36,21 +43,25 @@ async function sendEmail(subject: string, body: string, settings: RuntimeSetting
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendNtfy(subject: string, body: string, settings: RuntimeSettings): Promise<void> {
|
async function sendNtfy(subject: string, body: string, settings: RuntimeSettings, clickUrl?: string | null): Promise<void> {
|
||||||
if (!settings.ntfyServerUrl || !settings.ntfyTopic) {
|
if (!settings.ntfyServerUrl || !settings.ntfyTopic) {
|
||||||
logger.debug("ntfy configuration missing; skipping push notification.");
|
logger.debug("ntfy configuration missing; skipping push notification.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = `${settings.ntfyServerUrl.replace(/\/$/, "")}/${settings.ntfyTopic}`;
|
const url = new URL(`${settings.ntfyServerUrl.replace(/\/$/, "")}/${settings.ntfyTopic}`);
|
||||||
|
url.searchParams.set("title", subject);
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
Title: subject
|
"Content-Type": "text/plain; charset=utf-8"
|
||||||
};
|
};
|
||||||
if (settings.ntfyToken) {
|
if (settings.ntfyToken) {
|
||||||
headers.Authorization = `Bearer ${settings.ntfyToken}`;
|
headers.Authorization = `Bearer ${settings.ntfyToken}`;
|
||||||
}
|
}
|
||||||
if (settings.ntfyPriority) {
|
if (settings.ntfyPriority) {
|
||||||
headers.Priority = settings.ntfyPriority;
|
url.searchParams.set("priority", settings.ntfyPriority);
|
||||||
|
}
|
||||||
|
if (clickUrl) {
|
||||||
|
url.searchParams.set("click", clickUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
@@ -65,13 +76,12 @@ async function sendNtfy(subject: string, body: string, settings: RuntimeSettings
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendDeadlineNotifications(subject: string, lines: string[], settings: RuntimeSettings) {
|
export async function sendDeadlineNotifications(content: NotificationContent, settings: RuntimeSettings) {
|
||||||
const message = lines.join("\n");
|
|
||||||
const tasks: Array<Promise<void>> = [];
|
const tasks: Array<Promise<void>> = [];
|
||||||
|
|
||||||
if (settings.mailServer && settings.mailFrom && settings.mailTo) {
|
if (settings.mailServer && settings.mailFrom && settings.mailTo) {
|
||||||
tasks.push(
|
tasks.push(
|
||||||
sendEmail(subject, message, settings).then(() => {
|
sendEmail(content.subject, content.body, settings).then(() => {
|
||||||
logger.info("Deadline alert email sent.");
|
logger.info("Deadline alert email sent.");
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -79,7 +89,7 @@ export async function sendDeadlineNotifications(subject: string, lines: string[]
|
|||||||
|
|
||||||
if (settings.ntfyServerUrl && settings.ntfyTopic) {
|
if (settings.ntfyServerUrl && settings.ntfyTopic) {
|
||||||
tasks.push(
|
tasks.push(
|
||||||
sendNtfy(subject, message, settings).then(() => {
|
sendNtfy(content.subject, content.body, settings, content.clickUrl).then(() => {
|
||||||
logger.info("Deadline alert pushed via ntfy.");
|
logger.info("Deadline alert pushed via ntfy.");
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -101,12 +111,153 @@ export async function sendTestEmail(settings: RuntimeSettings): Promise<void> {
|
|||||||
if (!settings.mailServer || !settings.mailFrom || !settings.mailTo) {
|
if (!settings.mailServer || !settings.mailFrom || !settings.mailTo) {
|
||||||
throw new Error("E-Mail-Konfiguration unvollständig");
|
throw new Error("E-Mail-Konfiguration unvollständig");
|
||||||
}
|
}
|
||||||
await sendEmail("Contracts Companion Test", "Dies ist eine Testbenachrichtigung.", settings);
|
const locale = resolveLocale(settings);
|
||||||
|
const subject = locale === "de" ? "Contracts Companion – Test" : "Contracts Companion – Test";
|
||||||
|
const body =
|
||||||
|
locale === "de"
|
||||||
|
? "Dies ist eine Testbenachrichtigung des Contracts Companion."
|
||||||
|
: "This is a test notification from Contracts Companion.";
|
||||||
|
await sendEmail(subject, body, settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendTestNtfy(settings: RuntimeSettings): Promise<void> {
|
export async function sendTestNtfy(settings: RuntimeSettings): Promise<void> {
|
||||||
if (!settings.ntfyServerUrl || !settings.ntfyTopic) {
|
if (!settings.ntfyServerUrl || !settings.ntfyTopic) {
|
||||||
throw new Error("ntfy-Konfiguration unvollständig");
|
throw new Error("ntfy-Konfiguration unvollständig");
|
||||||
}
|
}
|
||||||
await sendNtfy("Contracts Companion Test", "Dies ist eine Testbenachrichtigung.", settings);
|
const locale = resolveLocale(settings);
|
||||||
|
const subject = locale === "de" ? "Contracts Companion – Test" : "Contracts Companion – Test";
|
||||||
|
const body =
|
||||||
|
locale === "de"
|
||||||
|
? "Dies ist eine Testbenachrichtigung des Contracts Companion."
|
||||||
|
: "This is a test notification from Contracts Companion.";
|
||||||
|
await sendNtfy(subject, body, settings, settings.appExternalUrl ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLocale(settings: RuntimeSettings): "de" | "en" {
|
||||||
|
const value = settings.appLocale?.toLowerCase() ?? config.appLocale ?? "de";
|
||||||
|
if (value.startsWith("en")) {
|
||||||
|
return "en";
|
||||||
|
}
|
||||||
|
return "de";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateLabel(date: string | null | undefined, locale: "de" | "en"): string {
|
||||||
|
if (!date) {
|
||||||
|
return locale === "de" ? "Unbekanntes Datum" : "Unknown date";
|
||||||
|
}
|
||||||
|
const formatter = new Intl.DateTimeFormat(locale === "de" ? "de-DE" : "en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "2-digit"
|
||||||
|
});
|
||||||
|
return formatter.format(new Date(`${date}T00:00:00Z`));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDaysLabel(days: number | null | undefined, locale: "de" | "en"): string {
|
||||||
|
if (days === null || days === undefined) {
|
||||||
|
return locale === "de" ? "Restlaufzeit unbekannt" : "Remaining days unknown";
|
||||||
|
}
|
||||||
|
if (days < 0) {
|
||||||
|
const overdue = Math.abs(days);
|
||||||
|
if (locale === "de") {
|
||||||
|
return overdue === 1 ? "Seit 1 Tag überfällig" : `Seit ${overdue} Tagen überfällig`;
|
||||||
|
}
|
||||||
|
return overdue === 1 ? "Overdue by 1 day" : `Overdue by ${overdue} days`;
|
||||||
|
}
|
||||||
|
if (days === 0) {
|
||||||
|
return locale === "de" ? "Heute fällig" : "Due today";
|
||||||
|
}
|
||||||
|
if (locale === "de") {
|
||||||
|
return days === 1 ? "Noch 1 Tag" : `Noch ${days} Tage`;
|
||||||
|
}
|
||||||
|
return days === 1 ? "1 day left" : `${days} days left`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type LinkInfo = {
|
||||||
|
label: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildLinks(
|
||||||
|
item: UpcomingDeadline,
|
||||||
|
appUrl: string | null,
|
||||||
|
paperlessUrl: string | null,
|
||||||
|
locale: "de" | "en"
|
||||||
|
): LinkInfo[] {
|
||||||
|
const lines: LinkInfo[] = [];
|
||||||
|
if (appUrl) {
|
||||||
|
const label = locale === "de" ? "Vertrag" : "Contract";
|
||||||
|
lines.push({ label, url: `${appUrl.replace(/\/$/, "")}/contracts/${item.id}` });
|
||||||
|
}
|
||||||
|
if (paperlessUrl && item.paperlessDocumentId) {
|
||||||
|
const label = locale === "de" ? "Paperless-Dokument" : "Paperless document";
|
||||||
|
lines.push({ label, url: `${paperlessUrl.replace(/\/$/, "")}/documents/${item.paperlessDocumentId}` });
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function composeDeadlineNotification(
|
||||||
|
deadlines: UpcomingDeadline[],
|
||||||
|
settings: RuntimeSettings
|
||||||
|
): NotificationContent {
|
||||||
|
const locale = resolveLocale(settings);
|
||||||
|
const count = deadlines.length;
|
||||||
|
const subject = locale === "de"
|
||||||
|
? (count === 1 ? "Eine Kündigungsfrist steht an" : `${count} Kündigungsfristen stehen an`)
|
||||||
|
: (count === 1 ? "Contract deadline due soon" : `${count} contract deadlines due soon`);
|
||||||
|
|
||||||
|
const header = locale === "de"
|
||||||
|
? "🔔 Vertragswarnung"
|
||||||
|
: "🔔 Contract reminder";
|
||||||
|
|
||||||
|
const appUrl = settings.appExternalUrl ?? null;
|
||||||
|
const paperlessUrl = settings.paperlessExternalUrl ?? settings.paperlessBaseUrl ?? null;
|
||||||
|
let primaryLink: string | null = appUrl;
|
||||||
|
|
||||||
|
const entries = deadlines.map((item) => {
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push(`• ${item.title} (#${item.id})`);
|
||||||
|
if (item.provider) {
|
||||||
|
lines.push(` ${locale === "de" ? "Anbieter" : "Provider"}: ${item.provider}`);
|
||||||
|
}
|
||||||
|
lines.push(
|
||||||
|
` ${locale === "de" ? "Kündigen bis" : "Cancel by"}: ${formatDateLabel(
|
||||||
|
item.terminationDeadline,
|
||||||
|
locale
|
||||||
|
)} (${formatDaysLabel(item.daysUntilDeadline ?? null, locale)})`
|
||||||
|
);
|
||||||
|
const linkInfos = buildLinks(item, appUrl, paperlessUrl, locale);
|
||||||
|
if (!primaryLink) {
|
||||||
|
const contractLink = linkInfos.find((info) => info.url.includes("/contracts/"));
|
||||||
|
const fallback = linkInfos[0];
|
||||||
|
primaryLink = contractLink?.url ?? fallback?.url ?? null;
|
||||||
|
}
|
||||||
|
lines.push(...linkInfos.map((link) => ` ${link.label}: ${link.url}`));
|
||||||
|
return lines.join("\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
const footer: string[] = [];
|
||||||
|
if (appUrl) {
|
||||||
|
footer.push(
|
||||||
|
`${locale === "de" ? "Zur Übersicht" : "Open dashboard"}: ${appUrl.replace(/\/$/, "")}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
footer.push(
|
||||||
|
locale === "de"
|
||||||
|
? "Tipp: Hinterlege die externe URL der App in den Einstellungen, um Direktlinks zu erhalten."
|
||||||
|
: "Tip: Configure the app external URL in settings to enable direct links."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bodyParts: string[] = [header];
|
||||||
|
const entriesBlock = entries.join("\n\n");
|
||||||
|
if (entriesBlock) {
|
||||||
|
bodyParts.push("", entriesBlock);
|
||||||
|
}
|
||||||
|
if (footer.length > 0) {
|
||||||
|
bodyParts.push("", ...footer);
|
||||||
|
}
|
||||||
|
const body = bodyParts.join("\n");
|
||||||
|
|
||||||
|
return { subject, body, clickUrl: primaryLink };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
export interface RuntimeSettings {
|
export interface RuntimeSettings {
|
||||||
paperlessBaseUrl: string | null;
|
paperlessBaseUrl: string | null;
|
||||||
paperlessExternalUrl: string | null;
|
paperlessExternalUrl: string | null;
|
||||||
|
appExternalUrl: string | null;
|
||||||
|
appLocale: string;
|
||||||
paperlessToken: string | null;
|
paperlessToken: string | null;
|
||||||
schedulerIntervalMinutes: number;
|
schedulerIntervalMinutes: number;
|
||||||
alertDaysBefore: number;
|
alertDaysBefore: number;
|
||||||
@@ -65,6 +67,19 @@ function coerceString(value: unknown, fallback: string | null): string | null {
|
|||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeLocale(value: unknown, fallback: string): string {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const lower = value.toLowerCase();
|
||||||
|
if (lower.startsWith("de")) {
|
||||||
|
return "de";
|
||||||
|
}
|
||||||
|
if (lower.startsWith("en")) {
|
||||||
|
return "en";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
export function getRuntimeSettings(): RuntimeSettings {
|
export function getRuntimeSettings(): RuntimeSettings {
|
||||||
const stored = listSettings();
|
const stored = listSettings();
|
||||||
|
|
||||||
@@ -78,6 +93,8 @@ export function getRuntimeSettings(): RuntimeSettings {
|
|||||||
return {
|
return {
|
||||||
paperlessBaseUrl: coerceString(stored.paperlessBaseUrl, config.paperlessBaseUrl ?? null),
|
paperlessBaseUrl: coerceString(stored.paperlessBaseUrl, config.paperlessBaseUrl ?? null),
|
||||||
paperlessExternalUrl: coerceString(stored.paperlessExternalUrl, config.paperlessExternalUrl ?? null),
|
paperlessExternalUrl: coerceString(stored.paperlessExternalUrl, config.paperlessExternalUrl ?? null),
|
||||||
|
appExternalUrl: coerceString(stored.appExternalUrl, config.appExternalUrl ?? null),
|
||||||
|
appLocale: normalizeLocale(stored.appLocale, config.appLocale),
|
||||||
paperlessToken: coerceString(stored.paperlessToken, config.paperlessToken ?? null),
|
paperlessToken: coerceString(stored.paperlessToken, config.paperlessToken ?? null),
|
||||||
schedulerIntervalMinutes,
|
schedulerIntervalMinutes,
|
||||||
alertDaysBefore,
|
alertDaysBefore,
|
||||||
@@ -104,12 +121,16 @@ export function getRuntimeSettings(): RuntimeSettings {
|
|||||||
export function updateRuntimeSettings(update: Partial<RuntimeSettings>): RuntimeSettings {
|
export function updateRuntimeSettings(update: Partial<RuntimeSettings>): RuntimeSettings {
|
||||||
const keys = Object.keys(update) as SettingKey[];
|
const keys = Object.keys(update) as SettingKey[];
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
const value = update[key as keyof RuntimeSettings];
|
let value = update[key as keyof RuntimeSettings];
|
||||||
if (value === undefined || value === null || value === "") {
|
if (value === undefined || value === null || value === "") {
|
||||||
removeSetting(key);
|
removeSetting(key);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (key === "appLocale") {
|
||||||
|
value = normalizeLocale(value, config.appLocale);
|
||||||
|
}
|
||||||
|
|
||||||
if (numericKeys.has(key)) {
|
if (numericKeys.has(key)) {
|
||||||
const numericValue = coerceNumber(value, 0);
|
const numericValue = coerceNumber(value, 0);
|
||||||
setSetting(key, numericValue);
|
setSetting(key, numericValue);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { config } from "./config.js";
|
import { config } from "./config.js";
|
||||||
import { listUpcomingDeadlines } from "./contractsStore.js";
|
import { listUpcomingDeadlines } from "./contractsStore.js";
|
||||||
import { createLogger } from "./logger.js";
|
import { createLogger } from "./logger.js";
|
||||||
import { sendDeadlineNotifications } from "./notifications.js";
|
import { composeDeadlineNotification, sendDeadlineNotifications } from "./notifications.js";
|
||||||
import { getRuntimeSettings } from "./runtimeSettings.js";
|
import { getRuntimeSettings } from "./runtimeSettings.js";
|
||||||
|
|
||||||
const logger = createLogger(config.logLevel);
|
const logger = createLogger(config.logLevel);
|
||||||
@@ -62,11 +62,6 @@ export class DeadlineMonitor {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lines = deadlines.map(
|
|
||||||
(item) =>
|
|
||||||
`${item.title} (#${item.id}) — cancel by ${item.terminationDeadline} (${item.daysUntilDeadline} days left)`
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const item of deadlines) {
|
for (const item of deadlines) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
"Upcoming deadline: %s (provider=%s, documentId=%s, terminate by %s, days=%s)",
|
"Upcoming deadline: %s (provider=%s, documentId=%s, terminate by %s, days=%s)",
|
||||||
@@ -78,7 +73,8 @@ export class DeadlineMonitor {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await sendDeadlineNotifications("Contract termination reminder", lines, settings);
|
const notification = composeDeadlineNotification(deadlines, settings);
|
||||||
|
await sendDeadlineNotifications(notification, settings);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ const listStmt = db.prepare("SELECT key, value FROM settings");
|
|||||||
export type SettingKey =
|
export type SettingKey =
|
||||||
| "paperlessBaseUrl"
|
| "paperlessBaseUrl"
|
||||||
| "paperlessExternalUrl"
|
| "paperlessExternalUrl"
|
||||||
|
| "appExternalUrl"
|
||||||
|
| "appLocale"
|
||||||
| "paperlessToken"
|
| "paperlessToken"
|
||||||
| "schedulerIntervalMinutes"
|
| "schedulerIntervalMinutes"
|
||||||
| "alertDaysBefore"
|
| "alertDaysBefore"
|
||||||
|
|||||||
Reference in New Issue
Block a user