feat: add phone number filter and related translations in project list

This commit is contained in:
2025-10-17 16:00:44 +02:00
parent bd0345df1a
commit 27247477c9
2 changed files with 142 additions and 28 deletions

View File

@@ -20,15 +20,28 @@ export default function ProjectListPage() {
const [projects, setProjects] = useState([]); const [projects, setProjects] = useState([]);
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [filteredProjects, setFilteredProjects] = useState([]); const [filteredProjects, setFilteredProjects] = useState([]);
const [filters, setFilters] = useState({ const [filters, setFilters] = useState(() => {
status: 'all', // Load phoneOnly filter from localStorage
type: 'all', const savedPhoneOnly = typeof window !== 'undefined'
customer: 'all', ? localStorage.getItem('projectsPhoneOnlyFilter') === 'true'
mine: false : false;
return {
status: 'all',
type: 'all',
customer: 'all',
mine: false,
phoneOnly: savedPhoneOnly
};
}); });
const [customers, setCustomers] = useState([]); const [customers, setCustomers] = useState([]);
const [filtersExpanded, setFiltersExpanded] = useState(true); // Start expanded on mobile so users know filters exist const [filtersExpanded, setFiltersExpanded] = useState(true); // Start expanded on mobile so users know filters exist
const [searchMatchType, setSearchMatchType] = useState(null); // Track what type of match was found
// Helper function to normalize strings by removing spaces
const normalizeString = (str) => {
return str?.replace(/\s+/g, '') || '';
};
useEffect(() => { useEffect(() => {
fetch("/api/projects") fetch("/api/projects")
@@ -74,17 +87,37 @@ export default function ProjectListPage() {
// Apply search term // Apply search term
if (searchTerm.trim()) { if (searchTerm.trim()) {
const searchLower = searchTerm.toLowerCase(); const searchLower = searchTerm.toLowerCase();
filtered = filtered.filter((project) => { const searchNormalized = normalizeString(searchLower);
return ( let hasContactMatch = false;
filtered = filtered.map((project) => {
const isContactMatch = normalizeString(project.contact?.toLowerCase()).includes(searchNormalized);
if (isContactMatch) hasContactMatch = true;
// Add a flag to mark projects that matched on contact
return {
...project,
_matchedOnContact: isContactMatch
};
}).filter((project) => {
const baseMatches =
project.project_name?.toLowerCase().includes(searchLower) || project.project_name?.toLowerCase().includes(searchLower) ||
project.wp?.toLowerCase().includes(searchLower) || project.wp?.toLowerCase().includes(searchLower) ||
project.plot?.toLowerCase().includes(searchLower) || project.plot?.toLowerCase().includes(searchLower) ||
project.investment_number?.toLowerCase().includes(searchLower) || project.investment_number?.toLowerCase().includes(searchLower) ||
project.address?.toLowerCase().includes(searchLower) || project.address?.toLowerCase().includes(searchLower) ||
project.customer?.toLowerCase().includes(searchLower) || project.customer?.toLowerCase().includes(searchLower) ||
project.investor?.toLowerCase().includes(searchLower) project.investor?.toLowerCase().includes(searchLower);
);
// Include contact matches only if phoneOnly is enabled
const contactMatch = filters.phoneOnly ? project._matchedOnContact : false;
return baseMatches || contactMatch;
}); });
setSearchMatchType(hasContactMatch ? 'contact' : null);
} else {
setSearchMatchType(null);
} }
setFilteredProjects(filtered); setFilteredProjects(filtered);
@@ -107,10 +140,19 @@ export default function ProjectListPage() {
}; };
const handleFilterChange = (filterType, value) => { const handleFilterChange = (filterType, value) => {
setFilters(prev => ({ setFilters(prev => {
...prev, const newFilters = {
[filterType]: value ...prev,
})); [filterType]: value
};
// Save phoneOnly filter to localStorage
if (filterType === 'phoneOnly') {
localStorage.setItem('projectsPhoneOnlyFilter', value.toString());
}
return newFilters;
});
}; };
const clearAllFilters = () => { const clearAllFilters = () => {
@@ -118,8 +160,10 @@ export default function ProjectListPage() {
status: 'all', status: 'all',
type: 'all', type: 'all',
customer: 'all', customer: 'all',
mine: false mine: false,
phoneOnly: false
}); });
localStorage.setItem('projectsPhoneOnlyFilter', 'false');
setSearchTerm(''); setSearchTerm('');
}; };
@@ -149,7 +193,7 @@ export default function ProjectListPage() {
setFiltersExpanded(!filtersExpanded); setFiltersExpanded(!filtersExpanded);
}; };
const hasActiveFilters = filters.status !== 'all' || filters.type !== 'all' || filters.customer !== 'all' || filters.mine || searchTerm.trim() !== ''; const hasActiveFilters = filters.status !== 'all' || filters.type !== 'all' || filters.customer !== 'all' || filters.mine || filters.phoneOnly || searchTerm.trim() !== '';
const getActiveFilterCount = () => { const getActiveFilterCount = () => {
let count = 0; let count = 0;
@@ -157,6 +201,7 @@ export default function ProjectListPage() {
if (filters.type !== 'all') count++; if (filters.type !== 'all') count++;
if (filters.customer !== 'all') count++; if (filters.customer !== 'all') count++;
if (filters.mine) count++; if (filters.mine) count++;
if (filters.phoneOnly) count++;
if (searchTerm.trim()) count++; if (searchTerm.trim()) count++;
return count; return count;
}; };
@@ -366,16 +411,17 @@ export default function ProjectListPage() {
</select> </select>
</div> </div>
{session?.user && ( <div className="flex items-center gap-2">
<button <button
onClick={() => handleFilterChange('mine', !filters.mine)} onClick={() => handleFilterChange('phoneOnly', !filters.phoneOnly)}
className={` className={`
inline-flex items-center space-x-2 px-3 py-1.5 rounded-full text-sm font-medium transition-all inline-flex items-center justify-center w-9 h-9 rounded-full text-sm font-medium transition-all
${filters.mine ${filters.phoneOnly
? 'bg-blue-100 text-blue-700 border-2 border-blue-300 dark:bg-blue-900/30 dark:text-blue-300 dark:border-blue-700' ? 'bg-blue-100 text-blue-700 border-2 border-blue-300 dark:bg-blue-900/30 dark:text-blue-300 dark:border-blue-700'
: 'bg-gray-100 text-gray-700 border-2 border-gray-200 hover:border-gray-300 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-700 dark:hover:border-gray-600' : 'bg-gray-100 text-gray-700 border-2 border-gray-200 hover:border-gray-300 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-700 dark:hover:border-gray-600'
} }
`} `}
title={filters.phoneOnly ? (t('projects.phoneSearchEnabled') || 'Wyszukiwanie po numerze włączone') : (t('projects.phoneSearchDisabled') || 'Wyszukiwanie po numerze wyłączone')}
> >
<svg <svg
className="w-4 h-4" className="w-4 h-4"
@@ -387,12 +433,39 @@ export default function ProjectListPage() {
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth={2} strokeWidth={2}
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"
/> />
</svg> </svg>
<span>{t('projects.mine') || 'Tylko moje'}</span>
</button> </button>
)}
{session?.user && (
<button
onClick={() => handleFilterChange('mine', !filters.mine)}
className={`
inline-flex items-center space-x-2 px-3 py-1.5 rounded-full text-sm font-medium transition-all
${filters.mine
? 'bg-blue-100 text-blue-700 border-2 border-blue-300 dark:bg-blue-900/30 dark:text-blue-300 dark:border-blue-700'
: 'bg-gray-100 text-gray-700 border-2 border-gray-200 hover:border-gray-300 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-700 dark:hover:border-gray-600'
}
`}
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
<span>{t('projects.mine') || 'Tylko moje'}</span>
</button>
)}
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -455,6 +528,32 @@ export default function ProjectListPage() {
</select> </select>
</div> </div>
<button
onClick={() => handleFilterChange('phoneOnly', !filters.phoneOnly)}
className={`
inline-flex items-center justify-center w-9 h-9 rounded-full text-sm font-medium transition-all
${filters.phoneOnly
? 'bg-blue-100 text-blue-700 border-2 border-blue-300 dark:bg-blue-900/30 dark:text-blue-300 dark:border-blue-700'
: 'bg-gray-100 text-gray-700 border-2 border-gray-200 hover:border-gray-300 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-700 dark:hover:border-gray-600'
}
`}
title={filters.phoneOnly ? (t('projects.phoneSearchEnabled') || 'Wyszukiwanie po numerze włączone') : (t('projects.phoneSearchDisabled') || 'Wyszukiwanie po numerze wyłączone')}
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"
/>
</svg>
</button>
{session?.user && ( {session?.user && (
<button <button
onClick={() => handleFilterChange('mine', !filters.mine)} onClick={() => handleFilterChange('mine', !filters.mine)}
@@ -490,7 +589,7 @@ export default function ProjectListPage() {
{t('projects.showingResults', { shown: filteredProjects.length, total: projects.length }) || `Wyświetlono ${filteredProjects.length} z ${projects.length} projektów`} {t('projects.showingResults', { shown: filteredProjects.length, total: projects.length }) || `Wyświetlono ${filteredProjects.length} z ${projects.length} projektów`}
</div> </div>
{(filters.status !== 'all' || filters.type !== 'all' || filters.customer !== 'all' || filters.mine || searchTerm) && ( {(filters.status !== 'all' || filters.type !== 'all' || filters.customer !== 'all' || filters.mine || filters.phoneOnly || searchTerm) && (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -615,16 +714,28 @@ export default function ProjectListPage() {
{filteredProjects.map((project, index) => ( {filteredProjects.map((project, index) => (
<tr <tr
key={project.project_id} key={project.project_id}
className={`border-b dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors ${ className={`border-b dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors ${
index % 2 === 0 ? "bg-white dark:bg-gray-800" : "bg-gray-25 dark:bg-gray-750" index % 2 === 0 ? "bg-white dark:bg-gray-800" : "bg-gray-25 dark:bg-gray-750"
}`} } ${project._matchedOnContact && filters.phoneOnly ? 'border-l-4 border-l-blue-500' : ''}`}
> >
<td className="px-1 py-3"> <td className="px-1 py-3">
<div className="flex items-center gap-1">
<Badge variant="secondary" size="sm" className="text-xs"> <Badge variant="secondary" size="sm" className="text-xs">
{project.project_number} {project.project_number}
</Badge> </Badge>
</td> {project._matchedOnContact && filters.phoneOnly && (
<td className="px-2 py-3 w-[200px] md:w-[250px]"> <span
className="inline-flex items-center justify-center w-5 h-5 bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300 rounded-full"
title={t('projects.contactNumberMatch') || 'Znaleziono numer kontaktowy'}
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
</svg>
</span>
)}
</div>
</td>
<td className="px-2 py-3 w-[200px] md:w-[250px]">
<Link <Link
href={`/projects/${project.project_id}`} href={`/projects/${project.project_id}`}
className="font-medium text-blue-600 hover:text-blue-800 transition-colors text-sm truncate block" className="font-medium text-blue-600 hover:text-blue-800 transition-colors text-sm truncate block"

View File

@@ -208,6 +208,9 @@ const translations = {
investmentNumber: "Numer inwestycji", investmentNumber: "Numer inwestycji",
enterInvestmentNumber: "Wprowadź numer inwestycji", enterInvestmentNumber: "Wprowadź numer inwestycji",
enterWP: "Wprowadź WP", enterWP: "Wprowadź WP",
contactNumberMatch: "Znaleziono numer kontaktowy",
phoneSearchEnabled: "Wyszukiwanie po numerze włączone",
phoneSearchDisabled: "Wyszukiwanie po numerze wyłączone",
placeholders: { placeholders: {
contact: "Wprowadź dane kontaktowe", contact: "Wprowadź dane kontaktowe",
coordinates: "np. 49.622958,20.629562", coordinates: "np. 49.622958,20.629562",