Files
play-life/play-life-web/src/components/ShoppingItemDetail.jsx
poignatov f1c12fd81a
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m23s
6.22.0: Авторасчёт сроков товаров по истории
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 11:37:39 +03:00

290 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useCallback } from 'react'
import { createPortal } from 'react-dom'
import { useAuth } from './auth/AuthContext'
import LoadingError from './LoadingError'
import Toast from './Toast'
import './TaskDetail.css'
function ShoppingItemDetail({ itemId, onClose, onRefresh, onItemCompleted, onNavigate }) {
const { authFetch } = useAuth()
const [item, setItem] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [volumeRemaining, setVolumeRemaining] = useState('')
const [volumePurchased, setVolumePurchased] = useState('')
const [isCompleting, setIsCompleting] = useState(false)
const [toastMessage, setToastMessage] = useState(null)
const fetchItem = useCallback(async () => {
try {
setLoading(true)
setError(null)
const response = await authFetch(`/api/shopping/items/${itemId}`)
if (!response.ok) {
throw new Error('Ошибка загрузки товара')
}
const data = await response.json()
setItem(data)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}, [itemId, authFetch])
useEffect(() => {
if (itemId) {
fetchItem()
} else {
setItem(null)
setLoading(true)
setError(null)
setVolumeRemaining('')
setVolumePurchased('')
}
}, [itemId, fetchItem])
const handleComplete = async () => {
if (!item) return
setIsCompleting(true)
try {
const payload = {}
if (volumeRemaining.trim()) {
payload.volume_remaining = parseFloat(volumeRemaining)
if (isNaN(payload.volume_remaining)) {
throw new Error('Неверное значение остатка')
}
} else {
payload.volume_remaining = item.estimated_remaining ?? 0
}
if (volumePurchased.trim()) {
payload.volume_purchased = parseFloat(volumePurchased)
if (isNaN(payload.volume_purchased)) {
throw new Error('Неверное значение докупки')
}
} else {
payload.volume_purchased = item.median_purchased ?? item.volume_base ?? 1
}
const response = await authFetch(`/api/shopping/items/${itemId}/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.error || 'Ошибка при выполнении')
}
onItemCompleted?.()
onRefresh?.()
onClose?.()
} catch (err) {
setToastMessage({ text: err.message || 'Ошибка', type: 'error' })
} finally {
setIsCompleting(false)
}
}
if (!itemId) return null
const modalContent = (
<div className="task-detail-modal-overlay" onClick={() => onClose?.()}>
<div className="task-detail-modal" onClick={(e) => e.stopPropagation()}>
<div className="task-detail-modal-header">
<h2
className="task-detail-title"
onClick={item ? () => {
onClose?.(true)
onNavigate?.('shopping-item-form', { itemId: itemId, boardId: item.board_id })
} : undefined}
style={{ cursor: item ? 'pointer' : 'default' }}
>
{loading ? 'Загрузка...' : error ? 'Ошибка' : item ? (
<>
{item.name}
<svg
className="task-detail-edit-icon"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path>
</svg>
</>
) : 'Товар'}
</h2>
<button onClick={() => onClose?.()} className="task-detail-close-button">
</button>
</div>
<div className="task-detail-modal-content">
{loading && (
<div className="loading">Загрузка...</div>
)}
{error && !loading && (
<LoadingError onRetry={fetchItem} />
)}
{!loading && !error && item && (
<>
<div className="shopping-item-description-card">
<div className="shopping-item-description">
{item.description ? (
item.description.split(/(https?:\/\/[^\s<>"'`,;!)\]]+)/gi).map((part, i) => {
if (/^https?:\/\//i.test(part)) {
let host
try {
host = new URL(part).host.replace(/^www\./, '')
} catch {
host = 'Открыть ссылку'
}
return (
<a key={i} href={part} target="_blank" rel="noopener noreferrer" className="shopping-item-description-link">
{host}
</a>
)
}
return <span key={i}>{part}</span>
})
) : (
<span style={{ color: '#9ca3af' }}>Описание отсутствует</span>
)}
</div>
<button
type="button"
className="shopping-item-history-button"
onClick={() => {
onClose?.(true)
onNavigate?.('shopping-item-history', { itemId: itemId })
}}
title="История"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="16" y1="13" x2="8" y2="13"></line>
<line x1="16" y1="17" x2="8" y2="17"></line>
<polyline points="10 9 9 9 8 9"></polyline>
</svg>
</button>
</div>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'flex-end', marginBottom: '0.75rem' }}>
<div style={{ flex: 1 }}>
<label className="progression-label">Остаток</label>
<div className="progression-input-wrapper">
<input
type="number"
step="any"
value={volumeRemaining}
onChange={(e) => setVolumeRemaining(e.target.value)}
placeholder={item.estimated_remaining != null ? Math.round(item.estimated_remaining * 10) / 10 + '' : '0'}
className="progression-input"
/>
{volumeRemaining && (
<button
type="button"
onClick={() => setVolumeRemaining('')}
style={{
position: 'absolute',
right: '8px',
top: '50%',
transform: 'translateY(-50%)',
background: 'none',
border: 'none',
color: '#9ca3af',
cursor: 'pointer',
fontSize: '1.1rem',
padding: '4px',
lineHeight: 1,
}}
>
</button>
)}
</div>
</div>
<div style={{ flex: 1 }}>
<label className="progression-label">Докуплено</label>
<div className="progression-input-wrapper">
<input
type="number"
step="any"
value={volumePurchased}
onChange={(e) => setVolumePurchased(e.target.value)}
placeholder={(item.median_purchased ?? item.volume_base ?? 1).toString()}
className="progression-input"
/>
<div className="progression-controls-capsule">
<button
type="button"
className="progression-control-btn progression-control-minus"
onClick={() => {
const base = item.median_purchased ?? item.volume_base ?? 1
const current = volumePurchased.trim() ? parseFloat(volumePurchased) : base
const step = item.volume_base || 1
setVolumePurchased(Math.max(0, current - step).toString())
}}
>
</button>
<button
type="button"
className="progression-control-btn progression-control-plus"
onClick={() => {
const base = item.median_purchased ?? item.volume_base ?? 1
const current = volumePurchased.trim() ? parseFloat(volumePurchased) : base
const step = item.volume_base || 1
setVolumePurchased((current + step).toString())
}}
>
+
</button>
</div>
</div>
</div>
</div>
<div className="task-action-left">
<button
onClick={handleComplete}
disabled={isCompleting}
className="action-button action-button-check"
>
{isCompleting ? (
<div className="shopping-item-complete-spinner"></div>
) : (
'Выполнить'
)}
</button>
</div>
</>
)}
</div>
{toastMessage && (
<Toast
message={toastMessage.text}
type={toastMessage.type}
onClose={() => setToastMessage(null)}
/>
)}
</div>
</div>
)
return typeof document !== 'undefined'
? createPortal(modalContent, document.body)
: modalContent
}
export default ShoppingItemDetail