diff --git a/backend/src/routes/resources/main.ts b/backend/src/routes/resources/main.ts index e0ed07f..2aa351d 100644 --- a/backend/src/routes/resources/main.ts +++ b/backend/src/routes/resources/main.ts @@ -7,7 +7,8 @@ import { and, count, inArray, - or + or, + sql, } from "drizzle-orm" import { resourceConfig } from "../../utils/resource.config"; @@ -23,15 +24,66 @@ import { decrypt, encrypt } from "../../utils/crypt"; function buildSearchCondition(columns: any[], search: string) { if (!search || !columns.length) return null - const term = `%${search.toLowerCase()}%` + const normalizeForSearch = (value: string) => + value + .toLowerCase() + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/ß/g, "ss") - const conditions = columns + const searchTermsRaw = search + .trim() + .toLowerCase() + .split(/\s+/) .filter(Boolean) - .map((col) => ilike(col, term)) - if (conditions.length === 0) return null + const searchTermsNormalized = searchTermsRaw.map(normalizeForSearch) - return or(...conditions) + const normalizeSqlExpr = (valueExpr: any) => sql` + lower( + replace( + replace( + replace( + replace( + replace( + replace( + replace(cast(${valueExpr} as text), 'Ä', 'A'), + 'Ö', 'O' + ), + 'Ü', 'U' + ), + 'ä', 'a' + ), + 'ö', 'o' + ), + 'ü', 'u' + ), + 'ß', 'ss' + ) + ) + ` + + const validColumns = columns.filter(Boolean) + if (validColumns.length === 0) return null + + // Alle Suchspalten zu einem String zusammenführen, damit Vor-/Nachname zuverlässig + // gemeinsam durchsuchbar sind (auch wenn in getrennten Feldern gespeichert). + const combinedRawExpr = sql`concat_ws(' ', ${sql.join(validColumns.map((col) => sql`coalesce(cast(${col} as text), '')`), sql`, `)})` + const combinedNormalizedExpr = normalizeSqlExpr(combinedRawExpr) + + const perTermConditions = searchTermsRaw.map((rawTerm, idx) => { + const normalizedTerm = searchTermsNormalized[idx] + const rawLike = `%${rawTerm}%` + const normalizedLike = `%${normalizedTerm}%` + + const rawCondition = ilike(combinedRawExpr, rawLike) + const normalizedCondition = sql`${combinedNormalizedExpr} like ${normalizedLike}` + + return or(rawCondition, normalizedCondition) + }) + + if (perTermConditions.length === 0) return null + return and(...perTermConditions) } function formatDiffValue(value: any): string { @@ -78,6 +130,13 @@ function applyResourceWhereFilters(resource: string, table: any, whereCond: any) return whereCond } +function isDateLikeField(key: string) { + if (key === "deliveryDateType") return false + if (key.includes("_at") || key.endsWith("At")) return true + if (/Date$/.test(key)) return true + return /(^|_|-)date($|_|-)/i.test(key) +} + function normalizeMemberPayload(payload: Record) { const infoData = payload.infoData && typeof payload.infoData === "object" ? payload.infoData : {} const normalized = { @@ -295,6 +354,8 @@ export default async function resourceRoutes(server: FastifyInstance) { let whereCond: any = eq(table.tenant, tenantId); whereCond = applyResourceWhereFilters(resource, table, whereCond) const searchCols: any[] = (config.searchColumns || []).map(c => table[c]); + const debugSearchColumnNames: string[] = [...(config.searchColumns || [])]; + const parsedFilters: Array<{ key: string; value: any }> = [] let countQuery = server.db.select({ value: count(table.id) }).from(table).$dynamic(); let mainQuery = server.db.select().from(table).$dynamic(); @@ -312,7 +373,10 @@ export default async function resourceRoutes(server: FastifyInstance) { mainQuery = mainQuery.leftJoin(relTable, eq(table[rel], relTable.id)); if (relConfig.searchColumns) { relConfig.searchColumns.forEach(c => { - if (relTable[c]) searchCols.push(relTable[c]); + if (relTable[c]) { + searchCols.push(relTable[c]); + debugSearchColumnNames.push(`${rel}.${c}`); + } }); } } @@ -321,6 +385,23 @@ export default async function resourceRoutes(server: FastifyInstance) { } if (search) { + if (resource === "customers") { + const rawSearch = search.trim() + const terms = rawSearch.toLowerCase().split(/\s+/).filter(Boolean) + const normalizedTerms = terms + .map((t) => t.normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/ß/g, "ss")) + + server.log.info({ + tag: "customer-search-debug", + search: rawSearch, + terms, + normalizedTerms, + searchColumns: debugSearchColumnNames, + page: pagination?.page ?? 1, + limit: pagination?.limit ?? 100, + }, "Paginated customer search request") + } + const searchCond = buildSearchCondition(searchCols, search.trim()); if (searchCond) whereCond = and(whereCond, searchCond); } @@ -329,6 +410,7 @@ export default async function resourceRoutes(server: FastifyInstance) { for (const [key, val] of Object.entries(filters)) { const col = (table as any)[key]; if (!col) continue; + parsedFilters.push({ key, value: val }) whereCond = Array.isArray(val) ? and(whereCond, inArray(col, val)) : and(whereCond, eq(col, val as any)); } } @@ -369,7 +451,24 @@ export default async function resourceRoutes(server: FastifyInstance) { } }); } - const dRows = await distinctQuery.where(whereCond); + let distinctWhereCond: any = eq(table.tenant, tenantId) + distinctWhereCond = applyResourceWhereFilters(resource, table, distinctWhereCond) + + if (search) { + const searchCond = buildSearchCondition(searchCols, search.trim()) + if (searchCond) distinctWhereCond = and(distinctWhereCond, searchCond) + } + + for (const f of parsedFilters) { + if (f.key === colName) continue + const filterCol = (table as any)[f.key] + if (!filterCol) continue + distinctWhereCond = Array.isArray(f.value) + ? and(distinctWhereCond, inArray(filterCol, f.value)) + : and(distinctWhereCond, eq(filterCol, f.value as any)) + } + + const dRows = await distinctQuery.where(distinctWhereCond); distinctValues[colName] = [...new Set(dRows.map(r => r.v).filter(v => v != null && v !== ""))].sort(); } } @@ -595,8 +694,15 @@ export default async function resourceRoutes(server: FastifyInstance) { } Object.keys(data).forEach((key) => { - if ((key.includes("_at") || key.includes("At") || key.toLowerCase().includes("date")) && key !== "deliveryDateType") { - data[key] = normalizeDate(data[key]) + const value = data[key] + const shouldNormalize = + isDateLikeField(key) && + value !== null && + value !== undefined && + (typeof value === "string" || typeof value === "number" || value instanceof Date) + + if (shouldNormalize) { + data[key] = normalizeDate(value) } }) diff --git a/backend/src/utils/resource.config.ts b/backend/src/utils/resource.config.ts index 3b51c70..7050f94 100644 --- a/backend/src/utils/resource.config.ts +++ b/backend/src/utils/resource.config.ts @@ -48,13 +48,13 @@ export const resourceConfig = { numberRangeHolder: "projectNumber" }, customers: { - searchColumns: ["name", "customerNumber", "firstname", "lastname", "notes"], + searchColumns: ["name", "nameAddition", "customerNumber", "firstname", "lastname", "notes"], mtmLoad: ["contacts","projects","plants","createddocuments","contracts","customerinventoryitems","customerspaces"], table: customers, numberRangeHolder: "customerNumber", }, members: { - searchColumns: ["name", "customerNumber", "firstname", "lastname", "notes"], + searchColumns: ["name", "nameAddition", "customerNumber", "firstname", "lastname", "notes"], mtmLoad: ["contacts","projects","plants","createddocuments","contracts"], table: customers, numberRangeHolder: "customerNumber", diff --git a/frontend/pages/standardEntity/[type]/index.vue b/frontend/pages/standardEntity/[type]/index.vue index 41bb1da..806b9ae 100644 --- a/frontend/pages/standardEntity/[type]/index.vue +++ b/frontend/pages/standardEntity/[type]/index.vue @@ -77,6 +77,7 @@ const sort = ref({ const columnsToFilter = ref({}) const showMobileFilter = ref(false) +let lastSearchRequestId = 0 //Functions @@ -86,6 +87,7 @@ function resetMobileFilters() { Object.keys(itemsMeta.value.distinctValues).forEach(key => { columnsToFilter.value[key] = [...itemsMeta.value.distinctValues[key]] + tempStore.clearFilter(type, key) }) showMobileFilter.value = false @@ -94,7 +96,16 @@ function resetMobileFilters() { function applyMobileFilters() { Object.keys(columnsToFilter.value).forEach(key => { - tempStore.modifyFilter(type, key, columnsToFilter.value[key]) + const selected = columnsToFilter.value[key] + const available = itemsMeta.value?.distinctValues?.[key] || [] + + if (!Array.isArray(selected) || selected.length === 0 || selected.length === available.length) { + tempStore.clearFilter(type, key) + columnsToFilter.value[key] = [...available] + return + } + + tempStore.modifyFilter(type, key, selected) }) showMobileFilter.value = false @@ -108,7 +119,7 @@ const clearSearchString = () => { } const performSearch = async () => { - tempStore.modifySearchString(type,searchString) + tempStore.modifySearchString(type, searchString.value) changePage(1,true) setupPage() } @@ -143,9 +154,65 @@ const isFiltered = computed(() => { }) }) +const initializeDistinctFilters = () => { + const distinctValues = itemsMeta.value?.distinctValues || {} + const storedDomainFilters = tempStore.filters[type] || {} + const normalizedStoredFilters = {} + + // Nur gültige, noch vorhandene Filterwerte aus dem TempStore übernehmen. + Object.entries(distinctValues).forEach(([column, availableValues]) => { + const available = Array.isArray(availableValues) ? availableValues : [] + const storedSelected = storedDomainFilters[column] + + if (!Array.isArray(storedSelected)) { + columnsToFilter.value[column] = [...available] + return + } + + const selected = storedSelected.filter((value) => available.includes(value)) + const isFullSelection = selected.length === 0 || selected.length === available.length + + if (isFullSelection) { + columnsToFilter.value[column] = [...available] + return + } + + columnsToFilter.value[column] = selected + normalizedStoredFilters[column] = selected + }) + + // Persistiert bereinigte Filter nur einmalig bei der Initialisierung. + tempStore.setFilters(type, normalizedStoredFilters) +} + +const syncDistinctFiltersFromStore = () => { + const distinctValues = itemsMeta.value?.distinctValues || {} + const storedDomainFilters = tempStore.filters[type] || {} + + Object.entries(distinctValues).forEach(([column, availableValues]) => { + const available = Array.isArray(availableValues) ? availableValues : [] + const storedSelected = storedDomainFilters[column] + + if (!Array.isArray(storedSelected) || storedSelected.length === 0) { + columnsToFilter.value[column] = [...available] + return + } + + const selected = storedSelected.filter((value) => available.includes(value)) + if (selected.length === 0 || selected.length === available.length) { + columnsToFilter.value[column] = [...available] + tempStore.clearFilter(type, column) + return + } + + columnsToFilter.value[column] = selected + }) +} + //SETUP const setupPage = async () => { + const currentRequestId = ++lastSearchRequestId loading.value = true setPageLayout(platformIsNative ? "mobile" : "default") @@ -159,9 +226,9 @@ const setupPage = async () => { archived:false } - Object.keys(columnsToFilter.value).forEach((column) => { - if(columnsToFilter.value[column].length !== itemsMeta.value.distinctValues[column].length) { - filters[column] = columnsToFilter.value[column] + Object.entries(tempStore.filters[type] || {}).forEach(([column, selected]) => { + if (Array.isArray(selected) && selected.length > 0) { + filters[column] = selected } }) @@ -177,16 +244,16 @@ const setupPage = async () => { distinctColumns: dataType.templateColumns.filter(i => i.distinct).map(i => i.key), }) + // Verhindert Race Conditions beim schnellen Tippen: + // Nur das Ergebnis des letzten Requests darf den State setzen. + if (currentRequestId !== lastSearchRequestId) return + items.value = data itemsMeta.value = meta if(!initialSetupDone.value){ - Object.keys(tempStore.filters[type] || {}).forEach((column) => { - columnsToFilter.value[column] = tempStore.filters[type][column] - }) - - Object.keys(itemsMeta.value.distinctValues).filter(i => !Object.keys(tempStore.filters[type] || {}).includes(i)).forEach(distinctValue => { - columnsToFilter.value[distinctValue] = itemsMeta.value.distinctValues[distinctValue] - }) + initializeDistinctFilters() + } else { + syncDistinctFiltersFromStore() } @@ -199,9 +266,18 @@ setupPage() const handleFilterChange = async (action,column) => { if(action === 'reset') { - columnsToFilter.value[column] = itemsMeta.value.distinctValues[column] + columnsToFilter.value[column] = [...(itemsMeta.value.distinctValues?.[column] || [])] + tempStore.clearFilter(type, column) } else if(action === 'change') { - tempStore.modifyFilter(type,column,columnsToFilter.value[column]) + const selected = columnsToFilter.value[column] + const available = itemsMeta.value.distinctValues?.[column] || [] + + if (!Array.isArray(selected) || selected.length === 0 || selected.length === available.length) { + tempStore.clearFilter(type, column) + columnsToFilter.value[column] = [...available] + } else { + tempStore.modifyFilter(type,column,selected) + } } setupPage() } diff --git a/frontend/stores/temp.js b/frontend/stores/temp.js index 1963837..3ab8283 100644 --- a/frontend/stores/temp.js +++ b/frontend/stores/temp.js @@ -49,6 +49,23 @@ export const useTempStore = defineStore('temp', () => { storeTempConfig() } + function setFilters(domain, nextFilters) { + filters.value[domain] = nextFilters + if (!filters.value[domain] || Object.keys(filters.value[domain]).length === 0) { + delete filters.value[domain] + } + storeTempConfig() + } + + function clearFilter(domain, type) { + if (!filters.value[domain]) return + delete filters.value[domain][type] + if (Object.keys(filters.value[domain]).length === 0) { + delete filters.value[domain] + } + storeTempConfig() + } + function modifyColumns(type, input) { columns.value[type] = input storeTempConfig() @@ -81,6 +98,8 @@ export const useTempStore = defineStore('temp', () => { clearSearchString, filters, modifyFilter, + setFilters, + clearFilter, columns, modifyColumns, modifyPages, @@ -89,4 +108,4 @@ export const useTempStore = defineStore('temp', () => { modifyBankingPeriod, // Neue Funktion exportiert settings } -}) \ No newline at end of file +})