localization for ntfy and mail
This commit is contained in:
@@ -17,12 +17,13 @@ import { fetchContract, fetchPaperlessDocument } from "../api/contracts";
|
||||
import { fetchServerConfig, ServerConfig } from "../api/config";
|
||||
import PageHeader from "../components/PageHeader";
|
||||
import { formatCurrency, formatDate } from "../utils/date";
|
||||
import { translateCategoryName } from "../utils/categories";
|
||||
|
||||
export default function ContractDetail() {
|
||||
const { contractId } = useParams<{ contractId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const id = Number(contractId);
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const { data: contract, isLoading } = useQuery({
|
||||
queryKey: ["contracts", id],
|
||||
@@ -56,6 +57,9 @@ export default function ContractDetail() {
|
||||
? t("contractForm.fields.autoRenew")
|
||||
: "–";
|
||||
const notesValue = contract?.notes ?? t("contractDetail.noNotes");
|
||||
const categoryValue = contract?.category
|
||||
? translateCategoryName(contract.category, i18n.language) || contract.category
|
||||
: "–";
|
||||
|
||||
if (!Number.isFinite(id)) {
|
||||
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.renewal")} value={renewalValue} />
|
||||
<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} />
|
||||
</Stack>
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
} from "../api/contracts";
|
||||
import { fetchServerConfig } from "../api/config";
|
||||
import { createCategory as apiCreateCategory, fetchCategories } from "../api/categories";
|
||||
import { getCanonicalDefaultCategoryName, translateCategoryName } from "../utils/categories";
|
||||
import PaperlessSearchDialog from "../components/PaperlessSearchDialog";
|
||||
import PageHeader from "../components/PageHeader";
|
||||
import { useSnackbar } from "../hooks/useSnackbar";
|
||||
@@ -94,7 +95,7 @@ export default function ContractForm({ mode }: Props) {
|
||||
const id = contractId ? Number(contractId) : null;
|
||||
const queryClient = useQueryClient();
|
||||
const { showMessage } = useSnackbar();
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const {
|
||||
control,
|
||||
@@ -160,7 +161,8 @@ export default function ContractForm({ mode }: Props) {
|
||||
await refetchCategories();
|
||||
queryClient.invalidateQueries({ queryKey: ["categories"] });
|
||||
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);
|
||||
setNewCategoryName("");
|
||||
},
|
||||
@@ -171,6 +173,34 @@ export default function ContractForm({ mode }: Props) {
|
||||
|
||||
const [categoryDialogOpen, setCategoryDialogOpen] = useState(false);
|
||||
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(
|
||||
() => (selectedDocument ? extractPaperlessProvider(selectedDocument) : null),
|
||||
@@ -342,7 +372,15 @@ export default function ContractForm({ mode }: Props) {
|
||||
render={({ field }) => {
|
||||
const options = categories ?? [];
|
||||
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 (
|
||||
<TextField
|
||||
label={t("contractForm.fields.category")}
|
||||
@@ -362,6 +400,7 @@ export default function ContractForm({ mode }: Props) {
|
||||
field.onChange(nextValue);
|
||||
}}
|
||||
SelectProps={{ displayEmpty: true }}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
disabled={loadingCategories}
|
||||
error={Boolean(categoriesError)}
|
||||
helperText={categoriesError ? t("contracts.categoryLoadError") : undefined}
|
||||
@@ -380,13 +419,13 @@ export default function ContractForm({ mode }: Props) {
|
||||
) : (
|
||||
options.map((category) => (
|
||||
<MenuItem key={category.id} value={category.name}>
|
||||
{category.name}
|
||||
{translateCategoryName(category.name, i18n.language) || category.name}
|
||||
</MenuItem>
|
||||
))
|
||||
)}
|
||||
{!loadingCategories && !categoriesError && value && !hasCurrentValue && (
|
||||
<MenuItem value={value as string}>
|
||||
{String(value)}
|
||||
{translateCategoryName(String(value), i18n.language) || String(value)}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem value="__add__">{t("contracts.categoryAdd")}</MenuItem>
|
||||
@@ -559,6 +598,12 @@ export default function ContractForm({ mode }: Props) {
|
||||
label={t("contracts.categoryNameLabel")}
|
||||
value={newCategoryName}
|
||||
onChange={(event) => setNewCategoryName(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
handleDialogCreateCategory();
|
||||
}
|
||||
}}
|
||||
fullWidth
|
||||
/>
|
||||
</DialogContent>
|
||||
@@ -570,15 +615,8 @@ export default function ContractForm({ mode }: Props) {
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const trimmed = newCategoryName.trim();
|
||||
if (!trimmed) {
|
||||
showMessage(t("contracts.categoryNameRequired"), "warning");
|
||||
return;
|
||||
}
|
||||
createCategoryMutation.mutate(trimmed);
|
||||
}}
|
||||
disabled={createCategoryMutation.isPending || !newCategoryName.trim()}
|
||||
onClick={handleDialogCreateCategory}
|
||||
disabled={createCategoryMutation.isPending || !trimmedDialogCategoryName}
|
||||
variant="contained"
|
||||
>
|
||||
{t("contracts.categoryCreate")}
|
||||
|
||||
@@ -34,12 +34,13 @@ import PageHeader from "../components/PageHeader";
|
||||
import { useSnackbar } from "../hooks/useSnackbar";
|
||||
import { Contract } from "../types";
|
||||
import { formatCurrency, formatDate } from "../utils/date";
|
||||
import { translateCategoryName } from "../utils/categories";
|
||||
|
||||
export default function ContractsList() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { showMessage } = useSnackbar();
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const [contractToDelete, setContractToDelete] = useState<Contract | null>(null);
|
||||
|
||||
const {
|
||||
@@ -72,7 +73,7 @@ export default function ContractsList() {
|
||||
return normalizedContracts.filter((contract) => {
|
||||
const searchMatch =
|
||||
!search ||
|
||||
[contract.title, contract.provider, contract.notes, contract.category]
|
||||
[contract.title, contract.provider, contract.notes, contract.category, translateCategoryName(contract.category ?? "", i18n.language)]
|
||||
.filter(Boolean)
|
||||
.some((field) => field!.toLowerCase().includes(search.toLowerCase()));
|
||||
|
||||
@@ -130,7 +131,7 @@ export default function ContractsList() {
|
||||
<MenuItem value="all">{t("contracts.filterAll")}</MenuItem>
|
||||
{(categoryOptions ?? []).map((item) => (
|
||||
<MenuItem key={item.id} value={item.name}>
|
||||
{item.name}
|
||||
{translateCategoryName(item.name, i18n.language) || item.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
@@ -190,7 +191,11 @@ export default function ContractsList() {
|
||||
</Typography>
|
||||
</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>{formatDate(contract.contractEndDate)}</TableCell>
|
||||
<TableCell>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Grid,
|
||||
IconButton,
|
||||
InputAdornment,
|
||||
MenuItem,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemAvatar,
|
||||
@@ -27,7 +28,7 @@ import {
|
||||
Typography
|
||||
} from "@mui/material";
|
||||
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 {
|
||||
@@ -47,6 +48,7 @@ import PageHeader from "../components/PageHeader";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { useSnackbar } from "../hooks/useSnackbar";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getCanonicalDefaultCategoryName, translateCategoryName } from "../utils/categories";
|
||||
|
||||
interface HealthResponse {
|
||||
status: string;
|
||||
@@ -55,6 +57,8 @@ interface HealthResponse {
|
||||
type FormValues = {
|
||||
paperlessBaseUrl: string;
|
||||
paperlessExternalUrl: string;
|
||||
appExternalUrl: string;
|
||||
appLocale: string;
|
||||
paperlessToken: string;
|
||||
schedulerIntervalMinutes: number;
|
||||
alertDaysBefore: number;
|
||||
@@ -76,6 +80,8 @@ type FormValues = {
|
||||
const defaultValues: FormValues = {
|
||||
paperlessBaseUrl: "",
|
||||
paperlessExternalUrl: "",
|
||||
appExternalUrl: "",
|
||||
appLocale: "de",
|
||||
paperlessToken: "",
|
||||
schedulerIntervalMinutes: 60,
|
||||
alertDaysBefore: 30,
|
||||
@@ -97,7 +103,7 @@ const defaultValues: FormValues = {
|
||||
export default function SettingsPage() {
|
||||
const { authEnabled } = useAuth();
|
||||
const { showMessage } = useSnackbar();
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -159,6 +165,8 @@ export default function SettingsPage() {
|
||||
const values: FormValues = {
|
||||
paperlessBaseUrl: settingsData.values.paperlessBaseUrl ?? "",
|
||||
paperlessExternalUrl: settingsData.values.paperlessExternalUrl ?? "",
|
||||
appExternalUrl: settingsData.values.appExternalUrl ?? "",
|
||||
appLocale: settingsData.values.appLocale ?? "de",
|
||||
paperlessToken: "",
|
||||
schedulerIntervalMinutes: settingsData.values.schedulerIntervalMinutes,
|
||||
alertDaysBefore: settingsData.values.alertDaysBefore,
|
||||
@@ -223,7 +231,8 @@ export default function SettingsPage() {
|
||||
await refetchCategories();
|
||||
queryClient.invalidateQueries({ queryKey: ["categories"] });
|
||||
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")
|
||||
});
|
||||
@@ -238,6 +247,38 @@ export default function SettingsPage() {
|
||||
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 icalSecret = settings?.icalSecret ?? null;
|
||||
const icalUrl = useMemo(() => {
|
||||
@@ -265,6 +306,12 @@ export default function SettingsPage() {
|
||||
if (formValues.paperlessExternalUrl !== initial.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) {
|
||||
payload.paperlessToken = null;
|
||||
@@ -521,19 +568,18 @@ export default function SettingsPage() {
|
||||
label={t("settings.categoryName")}
|
||||
value={newCategoryName}
|
||||
onChange={(event) => setNewCategoryName(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
handleAddCategory();
|
||||
}
|
||||
}}
|
||||
fullWidth
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => {
|
||||
const trimmed = newCategoryName.trim();
|
||||
if (!trimmed) {
|
||||
showMessage(t("settings.categoryNameRequired"), "warning");
|
||||
return;
|
||||
}
|
||||
createCategoryMutation.mutate(trimmed);
|
||||
}}
|
||||
disabled={createCategoryMutation.isPending || !newCategoryName.trim()}
|
||||
onClick={handleAddCategory}
|
||||
disabled={createCategoryMutation.isPending || !trimmedCategoryName}
|
||||
>
|
||||
{t("settings.categoryAdd")}
|
||||
</Button>
|
||||
@@ -543,7 +589,7 @@ export default function SettingsPage() {
|
||||
{categories.map((category) => (
|
||||
<Chip
|
||||
key={category.id}
|
||||
label={category.name}
|
||||
label={translateCategoryName(category.name, i18n.language) || category.name}
|
||||
onDelete={() => deleteCategoryMutation.mutate(category.id)}
|
||||
disabled={deleteCategoryMutation.isPending}
|
||||
/>
|
||||
@@ -579,6 +625,23 @@ export default function SettingsPage() {
|
||||
placeholder={t("settings.paperlessExample")}
|
||||
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" }}>
|
||||
<TextField
|
||||
label={paperlessTokenSet && !removePaperlessToken ? t("settings.paperlessTokenNew") : t("settings.paperlessToken")}
|
||||
|
||||
Reference in New Issue
Block a user