feat(country-province-city): add, edit functionality

This commit is contained in:
ghazall-ag
2025-11-17 00:40:53 +03:30
parent 77cc7534a0
commit 7cc442b600
20 changed files with 2008 additions and 280 deletions

649
src/pages/Location.jsx Normal file
View File

@@ -0,0 +1,649 @@
import React, { useEffect, useMemo, useState, useCallback } from 'react';
import DataTable from '../components/DataTable';
import { countryAPI, provinceAPI, cityAPI, currencyAPI } from '../services/api';
import { Plus, Search, Pencil, Trash2, MapPin, Globe, Building2 } from 'lucide-react';
import { ToastContainer, toast } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
import { getErrorMessage, getSuccessMessage } from '../utils/errorHandler';
const Location = () => {
const [activeTab, setActiveTab] = useState('country'); // 'country', 'province', 'city'
// Country states
const [countries, setCountries] = useState([]);
const [countriesLoading, setCountriesLoading] = useState(true);
const [countryFilter, setCountryFilter] = useState('');
const [isCountryModalOpen, setIsCountryModalOpen] = useState(false);
const [editingCountry, setEditingCountry] = useState(null);
const [countryForm, setCountryForm] = useState({ name: '', phoneCode: '', currencyCode: '', timeZoneName: '' });
const [currencies, setCurrencies] = useState([]);
// Province states
const [provinces, setProvinces] = useState([]);
const [provincesLoading, setProvincesLoading] = useState(true);
const [provinceFilter, setProvinceFilter] = useState('');
const [isProvinceModalOpen, setIsProvinceModalOpen] = useState(false);
const [editingProvince, setEditingProvince] = useState(null);
const [provinceForm, setProvinceForm] = useState({ countryId: '', provinceName: '' });
const [selectedCountryForProvince, setSelectedCountryForProvince] = useState(null);
// City states
const [cities, setCities] = useState([]);
const [citiesLoading, setCitiesLoading] = useState(true);
const [cityFilter, setCityFilter] = useState('');
const [isCityModalOpen, setIsCityModalOpen] = useState(false);
const [editingCity, setEditingCity] = useState(null);
const [cityForm, setCityForm] = useState({ provinceId: '', cityName: '' });
const [selectedProvinceForCity, setSelectedProvinceForCity] = useState(null);
const [provincesForCity, setProvincesForCity] = useState([]);
// دریافت ارزها برای dropdown
useEffect(() => {
const fetchCurrencies = async () => {
try {
const list = await currencyAPI.list();
setCurrencies(Array.isArray(list) ? list : []);
} catch (err) {
console.error('Failed to load currencies:', err);
}
};
fetchCurrencies();
}, []);
// دریافت کشورها
const fetchCountries = useCallback(async () => {
try {
setCountriesLoading(true);
const list = await countryAPI.listAll();
setCountries(Array.isArray(list) ? list : []);
} catch (err) {
toast.error(getErrorMessage(err));
} finally {
setCountriesLoading(false);
}
}, []);
// دریافت استان‌ها
const fetchProvinces = useCallback(async () => {
try {
setProvincesLoading(true);
const list = await provinceAPI.list();
setProvinces(Array.isArray(list) ? list : []);
} catch (err) {
toast.error(getErrorMessage(err));
} finally {
setProvincesLoading(false);
}
}, []);
// دریافت شهرها
const fetchCities = useCallback(async () => {
try {
setCitiesLoading(true);
const list = await cityAPI.list();
setCities(Array.isArray(list) ? list : []);
} catch (err) {
toast.error(getErrorMessage(err));
} finally {
setCitiesLoading(false);
}
}, []);
useEffect(() => {
if (activeTab === 'country') {
fetchCountries();
} else if (activeTab === 'province') {
fetchProvinces();
fetchCountries(); // برای dropdown
} else if (activeTab === 'city') {
fetchCities();
fetchProvinces(); // برای dropdown
}
}, [activeTab, fetchCountries, fetchProvinces, fetchCities]);
// ========== Country Functions ==========
const openCountryModal = (country = null) => {
if (country) {
setEditingCountry(country);
setCountryForm({
name: country.name || '',
phoneCode: country.phoneCode || '',
currencyCode: country.currencyCode || '',
timeZoneName: country.timeZoneName || country.timeZone || '',
});
} else {
setEditingCountry(null);
setCountryForm({ name: '', phoneCode: '', currencyCode: '', timeZoneName: '' });
}
setIsCountryModalOpen(true);
};
const onSaveCountry = async (e) => {
e.preventDefault();
try {
if (editingCountry) {
const countryId = editingCountry.id || editingCountry.countryId;
if (!countryId) {
toast.error('Country ID is missing');
console.error('Editing country:', editingCountry);
return;
}
console.log('Updating country with ID:', countryId, 'Payload:', countryForm);
await countryAPI.update(countryId, countryForm);
toast.success(getSuccessMessage({ data: { message: 'Country updated successfully' } }) || 'Country updated successfully');
} else {
await countryAPI.create(countryForm);
toast.success(getSuccessMessage({ data: { message: 'Country added successfully' } }) || 'Country added successfully');
}
await fetchCountries();
setIsCountryModalOpen(false);
setEditingCountry(null);
setCountryForm({ name: '', phoneCode: '', currencyCode: '', timeZoneName: '' });
} catch (err) {
console.error('Error saving country:', err);
toast.error(getErrorMessage(err));
}
};
// ========== Province Functions ==========
const openProvinceModal = (province = null) => {
if (province) {
setEditingProvince(province);
setProvinceForm({
countryId: province.countryId || '',
provinceName: province.name || '',
});
setSelectedCountryForProvince(province.countryId);
} else {
setEditingProvince(null);
setProvinceForm({ countryId: '', provinceName: '' });
setSelectedCountryForProvince(null);
}
setIsProvinceModalOpen(true);
};
const onSaveProvince = async (e) => {
e.preventDefault();
if (!provinceForm.countryId) {
toast.error('Please select a country');
return;
}
try {
if (editingProvince) {
const provinceId = editingProvince.id || editingProvince.provinceId;
if (!provinceId) {
toast.error('Province ID is missing');
console.error('Editing province:', editingProvince);
return;
}
console.log('Updating province with ID:', provinceId, 'Payload:', provinceForm);
await provinceAPI.update(provinceId, provinceForm);
toast.success(getSuccessMessage({ data: { message: 'Province updated successfully' } }) || 'Province updated successfully');
} else {
await provinceAPI.create(provinceForm);
toast.success(getSuccessMessage({ data: { message: 'Province added successfully' } }) || 'Province added successfully');
}
await fetchProvinces();
setIsProvinceModalOpen(false);
setEditingProvince(null);
setProvinceForm({ countryId: '', provinceName: '' });
setSelectedCountryForProvince(null);
} catch (err) {
console.error('Error saving province:', err);
toast.error(getErrorMessage(err));
}
};
// ========== City Functions ==========
const openCityModal = (city = null) => {
if (city) {
setEditingCity(city);
setCityForm({
provinceId: city.provinceId || '',
cityName: city.name || '',
});
setSelectedProvinceForCity(city.provinceId);
} else {
setEditingCity(null);
setCityForm({ provinceId: '', cityName: '' });
setSelectedProvinceForCity(null);
}
setIsCityModalOpen(true);
};
const onSaveCity = async (e) => {
e.preventDefault();
if (!cityForm.provinceId) {
toast.error('Please select a province');
return;
}
try {
if (editingCity) {
const cityId = editingCity.id || editingCity.cityId;
if (!cityId) {
toast.error('City ID is missing');
console.error('Editing city:', editingCity);
return;
}
console.log('Updating city with ID:', cityId, 'Payload:', cityForm);
await cityAPI.update(cityId, cityForm);
toast.success(getSuccessMessage({ data: { message: 'City updated successfully' } }) || 'City updated successfully');
} else {
await cityAPI.create(cityForm);
toast.success(getSuccessMessage({ data: { message: 'City added successfully' } }) || 'City added successfully');
}
await fetchCities();
setIsCityModalOpen(false);
setEditingCity(null);
setCityForm({ provinceId: '', cityName: '' });
setSelectedProvinceForCity(null);
} catch (err) {
console.error('Error saving city:', err);
toast.error(getErrorMessage(err));
}
};
// ========== Table Columns ==========
const countryColumns = useMemo(() => [
{ key: 'name', header: 'Name' },
{ key: 'phoneCode', header: 'Phone Code' },
{ key: 'currencyCode', header: 'Currency' },
{ key: 'timeZoneName', header: 'Time Zone', render: (val, row) => val || row.timeZone || '—' },
{
key: 'actions',
header: 'Actions',
render: (_val, row) => (
<div className="flex gap-2">
<button
onClick={() => openCountryModal(row)}
className="inline-flex items-center px-2 py-1 text-xs rounded-md bg-blue-100 text-blue-700 hover:opacity-90"
>
<Pencil className="h-3 w-3 mr-1" /> Edit
</button>
</div>
),
},
], []);
const provinceColumns = useMemo(() => [
{ key: 'name', header: 'Province Name' },
{ key: 'countryName', header: 'Country' },
{
key: 'actions',
header: 'Actions',
render: (_val, row) => (
<div className="flex gap-2">
<button
onClick={() => openProvinceModal(row)}
className="inline-flex items-center px-2 py-1 text-xs rounded-md bg-blue-100 text-blue-700 hover:opacity-90"
>
<Pencil className="h-3 w-3 mr-1" /> Edit
</button>
</div>
),
},
], []);
const cityColumns = useMemo(() => [
{ key: 'name', header: 'City Name' },
{ key: 'provinceName', header: 'Province' },
{
key: 'actions',
header: 'Actions',
render: (_val, row) => (
<div className="flex gap-2">
<button
onClick={() => openCityModal(row)}
className="inline-flex items-center px-2 py-1 text-xs rounded-md bg-blue-100 text-blue-700 hover:opacity-90"
>
<Pencil className="h-3 w-3 mr-1" /> Edit
</button>
</div>
),
},
], []);
// Filtered data
const filteredCountries = useMemo(() => {
if (!countryFilter) return countries;
const filter = countryFilter.toLowerCase();
return countries.filter(c =>
c.name?.toLowerCase().includes(filter) ||
c.phoneCode?.toLowerCase().includes(filter) ||
c.currencyCode?.toLowerCase().includes(filter)
);
}, [countries, countryFilter]);
const filteredProvinces = useMemo(() => {
if (!provinceFilter) return provinces;
const filter = provinceFilter.toLowerCase();
return provinces.filter(p =>
p.name?.toLowerCase().includes(filter) ||
p.countryName?.toLowerCase().includes(filter)
);
}, [provinces, provinceFilter]);
const filteredCities = useMemo(() => {
if (!cityFilter) return cities;
const filter = cityFilter.toLowerCase();
return cities.filter(c =>
c.name?.toLowerCase().includes(filter) ||
c.provinceName?.toLowerCase().includes(filter)
);
}, [cities, cityFilter]);
return (
<div className="p-6">
<ToastContainer position="top-right" autoClose={3000} />
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Location Management</h1>
<p className="text-gray-600 dark:text-gray-400">Manage countries, provinces, and cities</p>
</div>
{/* Tabs */}
<div className="mb-4 border-b border-gray-200 dark:border-gray-700">
<nav className="flex space-x-8">
<button
onClick={() => setActiveTab('country')}
className={`py-4 px-1 border-b-2 font-medium text-sm ${
activeTab === 'country'
? 'border-primary-500 text-primary-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
<Globe className="inline h-4 w-4 mr-2" />
Countries
</button>
<button
onClick={() => setActiveTab('province')}
className={`py-4 px-1 border-b-2 font-medium text-sm ${
activeTab === 'province'
? 'border-primary-500 text-primary-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
<MapPin className="inline h-4 w-4 mr-2" />
Provinces
</button>
<button
onClick={() => setActiveTab('city')}
className={`py-4 px-1 border-b-2 font-medium text-sm ${
activeTab === 'city'
? 'border-primary-500 text-primary-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
<Building2 className="inline h-4 w-4 mr-2" />
Cities
</button>
</nav>
</div>
{/* Country Tab */}
{activeTab === 'country' && (
<>
<div className="mb-4 flex justify-between items-center">
<div className="relative max-w-md flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
value={countryFilter}
onChange={(e) => setCountryFilter(e.target.value)}
placeholder="Filter countries..."
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<button
onClick={() => openCountryModal()}
className="btn-primary inline-flex items-center ml-4"
>
<Plus className="h-4 w-4 mr-2" /> Add Country
</button>
</div>
<DataTable
data={filteredCountries}
columns={countryColumns}
loading={countriesLoading}
searchable={false}
/>
</>
)}
{/* Province Tab */}
{activeTab === 'province' && (
<>
<div className="mb-4 flex justify-between items-center">
<div className="relative max-w-md flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
value={provinceFilter}
onChange={(e) => setProvinceFilter(e.target.value)}
placeholder="Filter provinces..."
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<button
onClick={() => openProvinceModal()}
className="btn-primary inline-flex items-center ml-4"
>
<Plus className="h-4 w-4 mr-2" /> Add Province
</button>
</div>
<DataTable
data={filteredProvinces}
columns={provinceColumns}
loading={provincesLoading}
searchable={false}
/>
</>
)}
{/* City Tab */}
{activeTab === 'city' && (
<>
<div className="mb-4 flex justify-between items-center">
<div className="relative max-w-md flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
value={cityFilter}
onChange={(e) => setCityFilter(e.target.value)}
placeholder="Filter cities..."
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<button
onClick={() => openCityModal()}
className="btn-primary inline-flex items-center ml-4"
>
<Plus className="h-4 w-4 mr-2" /> Add City
</button>
</div>
<DataTable
data={filteredCities}
columns={cityColumns}
loading={citiesLoading}
searchable={false}
/>
</>
)}
{/* Country Modal */}
{isCountryModalOpen && (
<>
<div className="fixed inset-0 bg-black bg-opacity-50 z-40" onClick={() => setIsCountryModalOpen(false)} />
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="w-full max-w-md card p-6 relative bg-white rounded-2xl shadow-lg">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
{editingCountry ? 'Edit Country' : 'Add Country'}
</h3>
<form onSubmit={onSaveCountry} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Name *</label>
<input
value={countryForm.name}
onChange={(e) => setCountryForm({ ...countryForm, name: e.target.value })}
className="w-full p-2 border rounded-lg"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Phone Code *</label>
<input
value={countryForm.phoneCode}
onChange={(e) => setCountryForm({ ...countryForm, phoneCode: e.target.value })}
className="w-full p-2 border rounded-lg"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Currency Code *</label>
<select
value={countryForm.currencyCode}
onChange={(e) => setCountryForm({ ...countryForm, currencyCode: e.target.value })}
className="w-full p-2 border rounded-lg"
required
>
<option value="">Select Currency</option>
{currencies.map((curr) => (
<option key={curr.currencyCode?.code} value={curr.currencyCode?.code}>
{curr.currencyCode?.code} - {curr.currencyCode?.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Time Zone *</label>
<input
value={countryForm.timeZoneName}
onChange={(e) => setCountryForm({ ...countryForm, timeZoneName: e.target.value })}
className="w-full p-2 border rounded-lg"
placeholder="e.g., UTC+3:30"
required
/>
</div>
<div className="flex justify-end gap-x-2">
<button type="button" onClick={() => setIsCountryModalOpen(false)} className="px-4 py-2 border rounded-lg">
Cancel
</button>
<button type="submit" className="btn-primary px-4 py-2 rounded-lg">
{editingCountry ? 'Update' : 'Add'}
</button>
</div>
</form>
</div>
</div>
</>
)}
{/* Province Modal */}
{isProvinceModalOpen && (
<>
<div className="fixed inset-0 bg-black bg-opacity-50 z-40" onClick={() => setIsProvinceModalOpen(false)} />
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="w-full max-w-md card p-6 relative bg-white rounded-2xl shadow-lg">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
{editingProvince ? 'Edit Province' : 'Add Province'}
</h3>
<form onSubmit={onSaveProvince} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Country *</label>
<select
value={provinceForm.countryId}
onChange={(e) => {
setProvinceForm({ ...provinceForm, countryId: e.target.value });
setSelectedCountryForProvince(e.target.value);
}}
className="w-full p-2 border rounded-lg"
required
>
<option value="">Select Country</option>
{countries.map((country) => (
<option key={country.id} value={country.id}>
{country.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Province Name *</label>
<input
value={provinceForm.provinceName}
onChange={(e) => setProvinceForm({ ...provinceForm, provinceName: e.target.value })}
className="w-full p-2 border rounded-lg"
required
/>
</div>
<div className="flex justify-end gap-x-2">
<button type="button" onClick={() => setIsProvinceModalOpen(false)} className="px-4 py-2 border rounded-lg">
Cancel
</button>
<button type="submit" className="btn-primary px-4 py-2 rounded-lg">
{editingProvince ? 'Update' : 'Add'}
</button>
</div>
</form>
</div>
</div>
</>
)}
{/* City Modal */}
{isCityModalOpen && (
<>
<div className="fixed inset-0 bg-black bg-opacity-50 z-40" onClick={() => setIsCityModalOpen(false)} />
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="w-full max-w-md card p-6 relative bg-white rounded-2xl shadow-lg">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
{editingCity ? 'Edit City' : 'Add City'}
</h3>
<form onSubmit={onSaveCity} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Province *</label>
<select
value={cityForm.provinceId}
onChange={(e) => {
setCityForm({ ...cityForm, provinceId: e.target.value });
setSelectedProvinceForCity(e.target.value);
}}
className="w-full p-2 border rounded-lg"
required
>
<option value="">Select Province</option>
{provinces.map((province) => (
<option key={province.id} value={province.id}>
{province.name} - {province.countryName}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">City Name *</label>
<input
value={cityForm.cityName}
onChange={(e) => setCityForm({ ...cityForm, cityName: e.target.value })}
className="w-full p-2 border rounded-lg"
required
/>
</div>
<div className="flex justify-end gap-x-2">
<button type="button" onClick={() => setIsCityModalOpen(false)} className="px-4 py-2 border rounded-lg">
Cancel
</button>
<button type="submit" className="btn-primary px-4 py-2 rounded-lg">
{editingCity ? 'Update' : 'Add'}
</button>
</div>
</form>
</div>
</div>
</>
)}
</div>
);
};
export default Location;