add toast
This commit is contained in:
parent
65a9dddf08
commit
a837e97e72
142
app/page.tsx
142
app/page.tsx
@ -64,6 +64,45 @@ export default function HomePage() {
|
|||||||
return mapping[employment as keyof typeof mapping] || employment
|
return mapping[employment as keyof typeof mapping] || employment
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatNullableField = (value: string | null | undefined) => {
|
||||||
|
if (!value || value === 'null') return 'Не указано'
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
const VacancyPlaceholder = () => (
|
||||||
|
<div className="bg-white rounded-lg shadow-md border border-gray-200 p-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="h-6 bg-gray-200 rounded animate-pulse"></div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="h-4 bg-gray-200 rounded animate-pulse w-3/4"></div>
|
||||||
|
<div className="h-4 bg-gray-200 rounded animate-pulse w-2/3"></div>
|
||||||
|
<div className="h-4 bg-gray-200 rounded animate-pulse w-1/2"></div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="h-4 bg-gray-200 rounded animate-pulse"></div>
|
||||||
|
<div className="h-4 bg-gray-200 rounded animate-pulse w-4/5"></div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div className="h-6 bg-gray-200 rounded-full animate-pulse w-24"></div>
|
||||||
|
<div className="h-4 bg-gray-200 rounded animate-pulse w-16"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const getVacanciesWithPlaceholders = (vacancies: VacancyRead[]) => {
|
||||||
|
const itemsPerRow = 3
|
||||||
|
const remainder = vacancies.length % itemsPerRow
|
||||||
|
const placeholdersNeeded = remainder === 0 ? 0 : itemsPerRow - remainder
|
||||||
|
|
||||||
|
const placeholders = Array(placeholdersNeeded).fill(null).map((_, index) => ({
|
||||||
|
id: `placeholder-${index}`,
|
||||||
|
isPlaceholder: true
|
||||||
|
}))
|
||||||
|
|
||||||
|
return [...vacancies, ...placeholders]
|
||||||
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center items-center min-h-[400px]">
|
<div className="flex justify-center items-center min-h-[400px]">
|
||||||
@ -123,57 +162,64 @@ export default function HomePage() {
|
|||||||
{/* Vacancies Grid */}
|
{/* Vacancies Grid */}
|
||||||
{!error && vacancies.length > 0 && (
|
{!error && vacancies.length > 0 && (
|
||||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{vacancies.map((vacancy) => (
|
{getVacanciesWithPlaceholders(vacancies).map((item, index) => {
|
||||||
<Link
|
if ('isPlaceholder' in item) {
|
||||||
key={vacancy.id}
|
return <VacancyPlaceholder key={item.id} />
|
||||||
href={`/vacancy/${vacancy.id}`}
|
}
|
||||||
className="block bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow border border-gray-200"
|
|
||||||
>
|
const vacancy = item as VacancyRead
|
||||||
<div className="p-6">
|
return (
|
||||||
<div className="flex items-start justify-between mb-4">
|
<Link
|
||||||
<h3 className="text-lg font-semibold text-gray-900 line-clamp-2">
|
key={vacancy.id}
|
||||||
{vacancy.title}
|
href={`/vacancy/${vacancy.id}`}
|
||||||
</h3>
|
className="block bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow border border-gray-200"
|
||||||
{vacancy.premium && (
|
>
|
||||||
<span className="ml-2 px-2 py-1 bg-yellow-100 text-yellow-800 text-xs font-medium rounded">
|
<div className="p-6">
|
||||||
Premium
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 line-clamp-2">
|
||||||
|
{vacancy.title}
|
||||||
|
</h3>
|
||||||
|
{vacancy.premium && (
|
||||||
|
<span className="ml-2 px-2 py-1 bg-yellow-100 text-yellow-800 text-xs font-medium rounded">
|
||||||
|
Premium
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 mb-4">
|
||||||
|
<div className="flex items-center text-gray-600 text-sm">
|
||||||
|
<Banknote className="h-4 w-4 mr-2" />
|
||||||
|
<span>{formatSalary(vacancy)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center text-gray-600 text-sm">
|
||||||
|
<MapPin className="h-4 w-4 mr-2" />
|
||||||
|
<span>{formatNullableField(vacancy.area_name)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center text-gray-600 text-sm">
|
||||||
|
<Clock className="h-4 w-4 mr-2" />
|
||||||
|
<span>{getExperienceText(vacancy.experience)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm text-gray-700 mb-4">
|
||||||
|
<p className="font-medium">{formatNullableField(vacancy.company_name)}</p>
|
||||||
|
<p className="line-clamp-2 mt-1">{formatNullableField(vacancy.description)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="px-3 py-1 bg-primary-100 text-primary-800 text-xs font-medium rounded-full">
|
||||||
|
{getEmploymentText(vacancy.employment_type)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{new Date(vacancy.published_at || vacancy.created_at).toLocaleDateString('ru-RU')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2 mb-4">
|
|
||||||
<div className="flex items-center text-gray-600 text-sm">
|
|
||||||
<Banknote className="h-4 w-4 mr-2" />
|
|
||||||
<span>{formatSalary(vacancy)}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center text-gray-600 text-sm">
|
|
||||||
<MapPin className="h-4 w-4 mr-2" />
|
|
||||||
<span>{vacancy.area_name || 'Не указано'}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center text-gray-600 text-sm">
|
|
||||||
<Clock className="h-4 w-4 mr-2" />
|
|
||||||
<span>{getExperienceText(vacancy.experience)}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Link>
|
||||||
<div className="text-sm text-gray-700 mb-4">
|
)
|
||||||
<p className="font-medium">{vacancy.company_name || 'Не указано'}</p>
|
})}
|
||||||
<p className="line-clamp-2 mt-1">{vacancy.description}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="px-3 py-1 bg-primary-100 text-primary-800 text-xs font-medium rounded-full">
|
|
||||||
{getEmploymentText(vacancy.employment_type)}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-gray-500">
|
|
||||||
{new Date(vacancy.published_at || vacancy.created_at).toLocaleDateString('ru-RU')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
@ -74,6 +74,11 @@ export default function VacancyPage() {
|
|||||||
return mapping[schedule as keyof typeof mapping] || schedule
|
return mapping[schedule as keyof typeof mapping] || schedule
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatNullableField = (value: string | null | undefined) => {
|
||||||
|
if (!value || value === 'null') return 'Не указано'
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center items-center min-h-[400px]">
|
<div className="flex justify-center items-center min-h-[400px]">
|
||||||
@ -131,7 +136,7 @@ export default function VacancyPage() {
|
|||||||
<div className="flex items-center mb-6">
|
<div className="flex items-center mb-6">
|
||||||
<Building className="h-5 w-5 text-gray-400 mr-2"/>
|
<Building className="h-5 w-5 text-gray-400 mr-2"/>
|
||||||
<span className="text-lg font-medium text-gray-900">
|
<span className="text-lg font-medium text-gray-900">
|
||||||
{vacancy.company_name || 'Не указано'}
|
{formatNullableField(vacancy.company_name)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -143,7 +148,7 @@ export default function VacancyPage() {
|
|||||||
|
|
||||||
<div className="flex items-center text-gray-600">
|
<div className="flex items-center text-gray-600">
|
||||||
<MapPin className="h-4 w-4 mr-2" />
|
<MapPin className="h-4 w-4 mr-2" />
|
||||||
<span>{vacancy.area_name || 'Не указано'}</span>
|
<span>{formatNullableField(vacancy.area_name)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center text-gray-600">
|
<div className="flex items-center text-gray-600">
|
||||||
@ -158,7 +163,7 @@ export default function VacancyPage() {
|
|||||||
|
|
||||||
<div className="flex items-center text-gray-600">
|
<div className="flex items-center text-gray-600">
|
||||||
<Calendar className="h-4 w-4 mr-2"/>
|
<Calendar className="h-4 w-4 mr-2"/>
|
||||||
<span>{ getScheduleText(vacancy.schedule) }</span>
|
<span>{ formatNullableField(getScheduleText(vacancy.schedule)) }</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{ vacancy.published_at && (
|
{ vacancy.published_at && (
|
||||||
@ -177,7 +182,7 @@ export default function VacancyPage() {
|
|||||||
</h2>
|
</h2>
|
||||||
<div className="prose prose-gray max-w-none">
|
<div className="prose prose-gray max-w-none">
|
||||||
<p className="whitespace-pre-line text-gray-700 leading-relaxed">
|
<p className="whitespace-pre-line text-gray-700 leading-relaxed">
|
||||||
{ vacancy.description }
|
{ formatNullableField(vacancy.description) }
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -189,7 +194,7 @@ export default function VacancyPage() {
|
|||||||
Ключевые навыки
|
Ключевые навыки
|
||||||
</h2>
|
</h2>
|
||||||
<div className="prose prose-gray max-w-none">
|
<div className="prose prose-gray max-w-none">
|
||||||
<p className="text-gray-700">{ vacancy.key_skills }</p>
|
<p className="text-gray-700">{ formatNullableField(vacancy.key_skills) }</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) }
|
) }
|
||||||
@ -202,7 +207,7 @@ export default function VacancyPage() {
|
|||||||
</h2>
|
</h2>
|
||||||
<div className="prose prose-gray max-w-none">
|
<div className="prose prose-gray max-w-none">
|
||||||
<p className="whitespace-pre-line text-gray-700 leading-relaxed">
|
<p className="whitespace-pre-line text-gray-700 leading-relaxed">
|
||||||
{ vacancy.company_description }
|
{ formatNullableField(vacancy.company_description) }
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -218,13 +223,13 @@ export default function VacancyPage() {
|
|||||||
{ vacancy.address && (
|
{ vacancy.address && (
|
||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
<MapPin className="h-4 w-4 mr-2 mt-0.5 flex-shrink-0"/>
|
<MapPin className="h-4 w-4 mr-2 mt-0.5 flex-shrink-0"/>
|
||||||
<span>{ vacancy.address }</span>
|
<span>{ formatNullableField(vacancy.address) }</span>
|
||||||
</div>
|
</div>
|
||||||
) }
|
) }
|
||||||
{ vacancy.metro_stations && (
|
{ vacancy.metro_stations && (
|
||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
<span className="text-sm font-medium mr-2">Метро:</span>
|
<span className="text-sm font-medium mr-2">Метро:</span>
|
||||||
<span className="text-sm">{ vacancy.metro_stations }</span>
|
<span className="text-sm">{ formatNullableField(vacancy.metro_stations) }</span>
|
||||||
</div>
|
</div>
|
||||||
) }
|
) }
|
||||||
</div>
|
</div>
|
||||||
@ -244,7 +249,7 @@ export default function VacancyPage() {
|
|||||||
{ vacancy.contacts_name && (
|
{ vacancy.contacts_name && (
|
||||||
<div className="flex items-center text-gray-700">
|
<div className="flex items-center text-gray-700">
|
||||||
<Users className="h-4 w-4 mr-2"/>
|
<Users className="h-4 w-4 mr-2"/>
|
||||||
<span>{ vacancy.contacts_name }</span>
|
<span>{ formatNullableField(vacancy.contacts_name) }</span>
|
||||||
</div>
|
</div>
|
||||||
) }
|
) }
|
||||||
{ vacancy.contacts_email && (
|
{ vacancy.contacts_email && (
|
||||||
@ -254,7 +259,7 @@ export default function VacancyPage() {
|
|||||||
href={ `mailto:${ vacancy.contacts_email }` }
|
href={ `mailto:${ vacancy.contacts_email }` }
|
||||||
className="hover:text-primary-600"
|
className="hover:text-primary-600"
|
||||||
>
|
>
|
||||||
{ vacancy.contacts_email }
|
{ formatNullableField(vacancy.contacts_email) }
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
) }
|
) }
|
||||||
@ -265,7 +270,7 @@ export default function VacancyPage() {
|
|||||||
href={ `tel:${ vacancy.contacts_phone }` }
|
href={ `tel:${ vacancy.contacts_phone }` }
|
||||||
className="hover:text-primary-600"
|
className="hover:text-primary-600"
|
||||||
>
|
>
|
||||||
{ vacancy.contacts_phone }
|
{ formatNullableField(vacancy.contacts_phone) }
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
) }
|
) }
|
||||||
|
@ -57,7 +57,6 @@ export default function VacancyUploadForm() {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
if (!selectedFile) {
|
if (!selectedFile) {
|
||||||
alert('Пожалуйста, выберите файл')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -71,7 +70,6 @@ export default function VacancyUploadForm() {
|
|||||||
setSelectedFile(null)
|
setSelectedFile(null)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка при загрузке файла:', error)
|
console.error('Ошибка при загрузке файла:', error)
|
||||||
alert('Произошла ошибка при загрузке файла')
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -7,7 +7,8 @@ export const useVacancies = (params?: GetVacanciesParams) => {
|
|||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['vacancies', params],
|
queryKey: ['vacancies', params],
|
||||||
queryFn: () => vacancyService.getVacancies(params),
|
queryFn: () => vacancyService.getVacancies(params),
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
staleTime: 0, // Данные сразу считаются устаревшими
|
||||||
|
refetchInterval: 5000, // Обновлять каждые 5 секунд
|
||||||
retry: 2,
|
retry: 2,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -23,39 +24,57 @@ export const useVacancy = (id: number) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const useParseVacancyFile = () => {
|
export const useParseVacancyFile = () => {
|
||||||
const queryClient = useQueryClient()
|
|
||||||
const pollIntervalRef = useRef<NodeJS.Timeout | null>(null)
|
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: ({ file, createVacancy }: { file: File; createVacancy?: boolean }) =>
|
mutationFn: ({ file, createVacancy }: { file: File; createVacancy?: boolean }) =>
|
||||||
vacancyService.parseFileAsync(file, createVacancy),
|
vacancyService.parseFileAsync(file, createVacancy),
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
// Показать уведомление об успешном запуске парсинга
|
// Показать toast уведомление
|
||||||
alert('Задача парсинга запущена! Скоро вакансия появится в списке.')
|
showToast('Задача парсинга запущена! Скоро вакансия появится в списке.')
|
||||||
|
|
||||||
// Начать опрос списка вакансий каждые 5 секунд
|
|
||||||
pollIntervalRef.current = setInterval(() => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['vacancies'] })
|
|
||||||
}, 5000)
|
|
||||||
|
|
||||||
// Остановить опрос через 2 минуты
|
|
||||||
setTimeout(() => {
|
|
||||||
if (pollIntervalRef.current) {
|
|
||||||
clearInterval(pollIntervalRef.current)
|
|
||||||
pollIntervalRef.current = null
|
|
||||||
}
|
|
||||||
}, 120000)
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Очистить интервал при размонтировании компонента
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (pollIntervalRef.current) {
|
|
||||||
clearInterval(pollIntervalRef.current)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return mutation
|
return mutation
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Простая функция для показа toast без React компонента
|
||||||
|
const showToast = (message: string) => {
|
||||||
|
// Создать элемент toast
|
||||||
|
const toast = document.createElement('div')
|
||||||
|
toast.textContent = message
|
||||||
|
toast.style.cssText = `
|
||||||
|
position: fixed;
|
||||||
|
bottom: 40px;
|
||||||
|
right: 20px;
|
||||||
|
background: #10b981;
|
||||||
|
color: white;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
z-index: 9999;
|
||||||
|
max-width: 300px;
|
||||||
|
word-wrap: break-word;
|
||||||
|
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||||
|
transform: translateX(100%);
|
||||||
|
`
|
||||||
|
|
||||||
|
// Добавить в DOM
|
||||||
|
document.body.appendChild(toast)
|
||||||
|
|
||||||
|
// Анимация появления
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.style.transform = 'translateX(0)'
|
||||||
|
}, 10)
|
||||||
|
|
||||||
|
// Удалить через 5 секунд
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.style.opacity = '0'
|
||||||
|
toast.style.transform = 'translateX(100%)'
|
||||||
|
setTimeout(() => {
|
||||||
|
if (toast.parentNode) {
|
||||||
|
document.body.removeChild(toast)
|
||||||
|
}
|
||||||
|
}, 300)
|
||||||
|
}, 10000)
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user