Merge branch 'main' of https://gittea.biveki.ru/Sfera/sfera
This commit is contained in:
@ -3,6 +3,38 @@
|
|||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
/* Кастомные скроллбары */
|
||||||
|
.scrollbar-thin {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-thumb-white\/20 {
|
||||||
|
scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-track-transparent {
|
||||||
|
scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Webkit скроллбары для браузеров на базе Chromium */
|
||||||
|
.scrollbar-thin::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-thin::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||||
|
background-color: rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
|
@ -30,7 +30,8 @@ import {
|
|||||||
import { WildberriesService } from '@/services/wildberries-service'
|
import { WildberriesService } from '@/services/wildberries-service'
|
||||||
import { useAuth } from '@/hooks/useAuth'
|
import { useAuth } from '@/hooks/useAuth'
|
||||||
import { useQuery, useMutation } from '@apollo/client'
|
import { useQuery, useMutation } from '@apollo/client'
|
||||||
import { GET_MY_COUNTERPARTIES } from '@/graphql/queries'
|
import { apolloClient } from '@/lib/apollo-client'
|
||||||
|
import { GET_MY_COUNTERPARTIES, GET_COUNTERPARTY_SERVICES, GET_COUNTERPARTY_SUPPLIES } from '@/graphql/queries'
|
||||||
import { CREATE_WILDBERRIES_SUPPLY } from '@/graphql/mutations'
|
import { CREATE_WILDBERRIES_SUPPLY } from '@/graphql/mutations'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
@ -58,12 +59,12 @@ interface WildberriesCard {
|
|||||||
interface SelectedCard {
|
interface SelectedCard {
|
||||||
card: WildberriesCard
|
card: WildberriesCard
|
||||||
selectedQuantity: number
|
selectedQuantity: number
|
||||||
selectedMarket: string
|
customPrice: number // Пользовательская цена за все количество
|
||||||
selectedPlace: string
|
selectedFulfillmentOrg: string // ID выбранной FF организации
|
||||||
sellerName: string
|
selectedFulfillmentServices: string[] // ID выбранных услуг FF (множественный выбор)
|
||||||
sellerPhone: string
|
selectedConsumableOrg: string // ID выбранной организации расходников
|
||||||
|
selectedConsumableServices: string[] // ID выбранных расходников (множественный выбор)
|
||||||
deliveryDate: string
|
deliveryDate: string
|
||||||
selectedServices: string[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FulfillmentService {
|
interface FulfillmentService {
|
||||||
@ -74,6 +75,13 @@ interface FulfillmentService {
|
|||||||
organizationName: string
|
organizationName: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Organization {
|
||||||
|
id: string
|
||||||
|
name?: string
|
||||||
|
fullName?: string
|
||||||
|
type: string
|
||||||
|
}
|
||||||
|
|
||||||
interface WBProductCardsProps {
|
interface WBProductCardsProps {
|
||||||
onBack: () => void
|
onBack: () => void
|
||||||
onComplete: (selectedCards: SelectedCard[]) => void
|
onComplete: (selectedCards: SelectedCard[]) => void
|
||||||
@ -88,6 +96,8 @@ export function WBProductCards({ onBack, onComplete }: WBProductCardsProps) {
|
|||||||
const [selectedCards, setSelectedCards] = useState<SelectedCard[]>([])
|
const [selectedCards, setSelectedCards] = useState<SelectedCard[]>([])
|
||||||
const [showSummary, setShowSummary] = useState(false)
|
const [showSummary, setShowSummary] = useState(false)
|
||||||
const [fulfillmentServices, setFulfillmentServices] = useState<FulfillmentService[]>([])
|
const [fulfillmentServices, setFulfillmentServices] = useState<FulfillmentService[]>([])
|
||||||
|
const [organizationServices, setOrganizationServices] = useState<{[orgId: string]: Array<{id: string, name: string, description?: string, price: number}>}>({})
|
||||||
|
const [organizationSupplies, setOrganizationSupplies] = useState<{[orgId: string]: Array<{id: string, name: string, description?: string, price: number}>}>({})
|
||||||
const [selectedCardForDetails, setSelectedCardForDetails] = useState<WildberriesCard | null>(null)
|
const [selectedCardForDetails, setSelectedCardForDetails] = useState<WildberriesCard | null>(null)
|
||||||
const [currentImageIndex, setCurrentImageIndex] = useState(0)
|
const [currentImageIndex, setCurrentImageIndex] = useState(0)
|
||||||
|
|
||||||
@ -261,6 +271,60 @@ export function WBProductCards({ onBack, onComplete }: WBProductCardsProps) {
|
|||||||
|
|
||||||
// Загружаем контрагентов-фулфилментов
|
// Загружаем контрагентов-фулфилментов
|
||||||
const { data: counterpartiesData } = useQuery(GET_MY_COUNTERPARTIES)
|
const { data: counterpartiesData } = useQuery(GET_MY_COUNTERPARTIES)
|
||||||
|
|
||||||
|
// Автоматически загружаем услуги и расходники для уже выбранных организаций
|
||||||
|
useEffect(() => {
|
||||||
|
selectedCards.forEach(sc => {
|
||||||
|
if (sc.selectedFulfillmentOrg && !organizationServices[sc.selectedFulfillmentOrg]) {
|
||||||
|
loadOrganizationServices(sc.selectedFulfillmentOrg)
|
||||||
|
}
|
||||||
|
if (sc.selectedConsumableOrg && !organizationSupplies[sc.selectedConsumableOrg]) {
|
||||||
|
loadOrganizationSupplies(sc.selectedConsumableOrg)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [selectedCards])
|
||||||
|
|
||||||
|
// Функция для загрузки услуг организации
|
||||||
|
const loadOrganizationServices = async (organizationId: string) => {
|
||||||
|
if (organizationServices[organizationId]) return // Уже загружены
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apolloClient.query({
|
||||||
|
query: GET_COUNTERPARTY_SERVICES,
|
||||||
|
variables: { organizationId }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.data?.counterpartyServices) {
|
||||||
|
setOrganizationServices(prev => ({
|
||||||
|
...prev,
|
||||||
|
[organizationId]: response.data.counterpartyServices
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки услуг организации:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для загрузки расходников организации
|
||||||
|
const loadOrganizationSupplies = async (organizationId: string) => {
|
||||||
|
if (organizationSupplies[organizationId]) return // Уже загружены
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apolloClient.query({
|
||||||
|
query: GET_COUNTERPARTY_SUPPLIES,
|
||||||
|
variables: { organizationId }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.data?.counterpartySupplies) {
|
||||||
|
setOrganizationSupplies(prev => ({
|
||||||
|
...prev,
|
||||||
|
[organizationId]: response.data.counterpartySupplies
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки расходников организации:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Мутация для создания поставки
|
// Мутация для создания поставки
|
||||||
const [createSupply, { loading: creatingSupply }] = useMutation(CREATE_WILDBERRIES_SUPPLY, {
|
const [createSupply, { loading: creatingSupply }] = useMutation(CREATE_WILDBERRIES_SUPPLY, {
|
||||||
@ -286,48 +350,7 @@ export function WBProductCards({ onBack, onComplete }: WBProductCardsProps) {
|
|||||||
{ value: 'food-city', label: 'Фуд Сити' }
|
{ value: 'food-city', label: 'Фуд Сити' }
|
||||||
]
|
]
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Загружаем услуги фулфилмента из контрагентов
|
|
||||||
if (counterpartiesData?.myCounterparties) {
|
|
||||||
interface Organization {
|
|
||||||
id: string
|
|
||||||
name?: string
|
|
||||||
fullName?: string
|
|
||||||
type: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const fulfillmentOrganizations = counterpartiesData.myCounterparties.filter(
|
|
||||||
(org: Organization) => org.type === 'FULFILLMENT'
|
|
||||||
)
|
|
||||||
|
|
||||||
// В реальном приложении здесь был бы запрос услуг для каждой организации
|
|
||||||
const mockServices: FulfillmentService[] = fulfillmentOrganizations.flatMap((org: Organization) => [
|
|
||||||
{
|
|
||||||
id: `${org.id}-packaging`,
|
|
||||||
name: 'Упаковка товаров',
|
|
||||||
description: 'Профессиональная упаковка товаров',
|
|
||||||
price: 50,
|
|
||||||
organizationName: org.name || org.fullName
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: `${org.id}-labeling`,
|
|
||||||
name: 'Маркировка товаров',
|
|
||||||
description: 'Нанесение этикеток и штрих-кодов',
|
|
||||||
price: 30,
|
|
||||||
organizationName: org.name || org.fullName
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: `${org.id}-quality-check`,
|
|
||||||
name: 'Контроль качества',
|
|
||||||
description: 'Проверка качества товаров',
|
|
||||||
price: 100,
|
|
||||||
organizationName: org.name || org.fullName
|
|
||||||
}
|
|
||||||
])
|
|
||||||
|
|
||||||
setFulfillmentServices(mockServices)
|
|
||||||
}
|
|
||||||
}, [counterpartiesData])
|
|
||||||
|
|
||||||
// Автоматически загружаем товары при открытии компонента
|
// Автоматически загружаем товары при открытии компонента
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -491,12 +514,12 @@ export function WBProductCards({ onBack, onComplete }: WBProductCardsProps) {
|
|||||||
const newSelectedCard: SelectedCard = {
|
const newSelectedCard: SelectedCard = {
|
||||||
card,
|
card,
|
||||||
selectedQuantity: value as number,
|
selectedQuantity: value as number,
|
||||||
selectedMarket: '',
|
customPrice: 0,
|
||||||
selectedPlace: '',
|
selectedFulfillmentOrg: '',
|
||||||
sellerName: '',
|
selectedFulfillmentServices: [],
|
||||||
sellerPhone: '',
|
selectedConsumableOrg: '',
|
||||||
deliveryDate: '',
|
selectedConsumableServices: [],
|
||||||
selectedServices: []
|
deliveryDate: ''
|
||||||
}
|
}
|
||||||
return [...prev, newSelectedCard]
|
return [...prev, newSelectedCard]
|
||||||
}
|
}
|
||||||
@ -518,14 +541,50 @@ export function WBProductCards({ onBack, onComplete }: WBProductCardsProps) {
|
|||||||
}).format(amount)
|
}).format(amount)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Функция для получения цены услуги по ID
|
||||||
|
const getServicePrice = (orgId: string, serviceId: string): number => {
|
||||||
|
const services = organizationServices[orgId]
|
||||||
|
if (!services) return 0
|
||||||
|
const service = services.find(s => s.id === serviceId)
|
||||||
|
return service ? service.price : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для получения цены расходника по ID
|
||||||
|
const getSupplyPrice = (orgId: string, supplyId: string): number => {
|
||||||
|
const supplies = organizationSupplies[orgId]
|
||||||
|
if (!supplies) return 0
|
||||||
|
const supply = supplies.find(s => s.id === supplyId)
|
||||||
|
return supply ? supply.price : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для расчета стоимости услуг и расходников за 1 штуку
|
||||||
|
const calculateAdditionalCostPerUnit = (sc: SelectedCard): number => {
|
||||||
|
let servicesCost = 0
|
||||||
|
let suppliesCost = 0
|
||||||
|
|
||||||
|
// Стоимость услуг фулфилмента
|
||||||
|
if (sc.selectedFulfillmentOrg && sc.selectedFulfillmentServices.length > 0) {
|
||||||
|
servicesCost = sc.selectedFulfillmentServices.reduce((sum, serviceId) => {
|
||||||
|
return sum + getServicePrice(sc.selectedFulfillmentOrg, serviceId)
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Стоимость расходных материалов
|
||||||
|
if (sc.selectedConsumableOrg && sc.selectedConsumableServices.length > 0) {
|
||||||
|
suppliesCost = sc.selectedConsumableServices.reduce((sum, supplyId) => {
|
||||||
|
return sum + getSupplyPrice(sc.selectedConsumableOrg, supplyId)
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return servicesCost + suppliesCost
|
||||||
|
}
|
||||||
|
|
||||||
const getTotalAmount = () => {
|
const getTotalAmount = () => {
|
||||||
return selectedCards.reduce((sum, sc) => {
|
return selectedCards.reduce((sum, sc) => {
|
||||||
const cardPrice = sc.card.sizes[0]?.discountedPrice || sc.card.sizes[0]?.price || 0
|
const additionalCostPerUnit = calculateAdditionalCostPerUnit(sc)
|
||||||
const servicesPrice = sc.selectedServices.reduce((serviceSum, serviceId) => {
|
const totalCostPerUnit = (sc.customPrice / sc.selectedQuantity) + additionalCostPerUnit
|
||||||
const service = fulfillmentServices.find(s => s.id === serviceId)
|
const totalCostForAllItems = totalCostPerUnit * sc.selectedQuantity
|
||||||
return serviceSum + (service?.price || 0)
|
return sum + totalCostForAllItems
|
||||||
}, 0)
|
|
||||||
return sum + (cardPrice + servicesPrice) * sc.selectedQuantity
|
|
||||||
}, 0)
|
}, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -533,11 +592,7 @@ export function WBProductCards({ onBack, onComplete }: WBProductCardsProps) {
|
|||||||
return selectedCards.reduce((sum, sc) => sum + sc.selectedQuantity, 0)
|
return selectedCards.reduce((sum, sc) => sum + sc.selectedQuantity, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
const applyServicesToAll = (serviceIds: string[]) => {
|
// Функция больше не нужна, так как услуги выбираются индивидуально
|
||||||
setSelectedCards(prev =>
|
|
||||||
prev.map(sc => ({ ...sc, selectedServices: serviceIds }))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCardClick = (card: WildberriesCard) => {
|
const handleCardClick = (card: WildberriesCard) => {
|
||||||
setSelectedCardForDetails(card)
|
setSelectedCardForDetails(card)
|
||||||
@ -570,17 +625,14 @@ export function WBProductCards({ onBack, onComplete }: WBProductCardsProps) {
|
|||||||
vendorCode: sc.card.vendorCode,
|
vendorCode: sc.card.vendorCode,
|
||||||
title: sc.card.title,
|
title: sc.card.title,
|
||||||
brand: sc.card.brand,
|
brand: sc.card.brand,
|
||||||
price: sc.card.sizes[0]?.price || 0,
|
|
||||||
discountedPrice: sc.card.sizes[0]?.discountedPrice || null,
|
|
||||||
quantity: sc.card.sizes[0]?.quantity || 0,
|
|
||||||
selectedQuantity: sc.selectedQuantity,
|
selectedQuantity: sc.selectedQuantity,
|
||||||
selectedMarket: sc.selectedMarket,
|
customPrice: sc.customPrice,
|
||||||
selectedPlace: sc.selectedPlace,
|
selectedFulfillmentOrg: sc.selectedFulfillmentOrg,
|
||||||
sellerName: sc.sellerName,
|
selectedFulfillmentServices: sc.selectedFulfillmentServices,
|
||||||
sellerPhone: sc.sellerPhone,
|
selectedConsumableOrg: sc.selectedConsumableOrg,
|
||||||
deliveryDate: sc.deliveryDate || null,
|
selectedConsumableServices: sc.selectedConsumableServices,
|
||||||
mediaFiles: sc.card.mediaFiles,
|
deliveryDate: sc.deliveryDate || null,
|
||||||
selectedServices: sc.selectedServices
|
mediaFiles: sc.card.mediaFiles
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -625,72 +677,245 @@ export function WBProductCards({ onBack, onComplete }: WBProductCardsProps) {
|
|||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
<div className="lg:col-span-2 space-y-4">
|
<div className="lg:col-span-2 space-y-4">
|
||||||
{selectedCards.map((sc) => {
|
{selectedCards.map((sc) => {
|
||||||
const cardPrice = sc.card.sizes[0]?.discountedPrice || sc.card.sizes[0]?.price || 0
|
const fulfillmentOrgs = (counterpartiesData?.myCounterparties || []).filter((org: Organization) => org.type === 'FULFILLMENT')
|
||||||
const servicesPrice = sc.selectedServices.reduce((sum, serviceId) => {
|
const consumableOrgs = (counterpartiesData?.myCounterparties || []).filter((org: Organization) => org.type === 'FULFILLMENT')
|
||||||
const service = fulfillmentServices.find(s => s.id === serviceId)
|
|
||||||
return sum + (service?.price || 0)
|
|
||||||
}, 0)
|
|
||||||
const totalPrice = (cardPrice + servicesPrice) * sc.selectedQuantity
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card key={sc.card.nmID} className="bg-white/10 backdrop-blur border-white/20 p-4">
|
<Card key={sc.card.nmID} className="bg-white/10 backdrop-blur border-white/20 p-4">
|
||||||
<div className="flex space-x-4">
|
<div className="flex space-x-4">
|
||||||
<img
|
<img
|
||||||
src={sc.card.mediaFiles?.[0] || '/api/placeholder/120/120'}
|
src={sc.card.mediaFiles?.[0] || '/api/placeholder/120/120'}
|
||||||
alt={sc.card.title}
|
alt={sc.card.title}
|
||||||
className="w-20 h-20 rounded-lg object-cover"
|
className="w-20 h-20 rounded-lg object-cover"
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 space-y-3">
|
<div className="flex-1 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-white font-medium">{sc.card.title}</h3>
|
<h3 className="text-white font-medium">{sc.card.title}</h3>
|
||||||
<p className="text-white/60 text-sm">{sc.card.vendorCode}</p>
|
<p className="text-white/60 text-sm">{sc.card.vendorCode}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
{/* Количество и цена */}
|
||||||
<span className="text-white/60">Количество:</span>
|
<div className="space-y-3">
|
||||||
<span className="text-white ml-2">{sc.selectedQuantity}</span>
|
<div>
|
||||||
|
<label className="text-white/60 text-sm">Количество:</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={sc.selectedQuantity}
|
||||||
|
onChange={(e) => updateCardSelection(sc.card, 'selectedQuantity', parseInt(e.target.value) || 0)}
|
||||||
|
className="bg-white/5 border-white/20 text-white mt-1"
|
||||||
|
min="1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-white/60 text-sm">Цена за единицу:</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={sc.customPrice === 0 ? '' : (sc.customPrice / sc.selectedQuantity).toFixed(2)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const pricePerUnit = e.target.value === '' ? 0 : parseFloat(e.target.value) || 0
|
||||||
|
const totalPrice = pricePerUnit * sc.selectedQuantity
|
||||||
|
updateCardSelection(sc.card, 'customPrice', totalPrice)
|
||||||
|
}}
|
||||||
|
className="bg-white/5 border-white/20 text-white mt-1"
|
||||||
|
placeholder="Введите цену за 1 штуку"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Показываем расчет дополнительных расходов */}
|
||||||
|
{(() => {
|
||||||
|
const additionalCost = calculateAdditionalCostPerUnit(sc)
|
||||||
|
if (additionalCost > 0) {
|
||||||
|
return (
|
||||||
|
<div className="mt-2 p-2 bg-blue-500/20 border border-blue-500/30 rounded text-xs">
|
||||||
|
<div className="text-blue-300 font-medium">Дополнительные расходы за 1 шт:</div>
|
||||||
|
{sc.selectedFulfillmentServices.length > 0 && (
|
||||||
|
<div className="text-blue-200">
|
||||||
|
Услуги: {sc.selectedFulfillmentServices.map(serviceId => {
|
||||||
|
const price = getServicePrice(sc.selectedFulfillmentOrg, serviceId)
|
||||||
|
const services = organizationServices[sc.selectedFulfillmentOrg]
|
||||||
|
const service = services?.find(s => s.id === serviceId)
|
||||||
|
return service ? `${service.name} (${price}₽)` : ''
|
||||||
|
}).join(', ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{sc.selectedConsumableServices.length > 0 && (
|
||||||
|
<div className="text-blue-200">
|
||||||
|
Расходники: {sc.selectedConsumableServices.map(supplyId => {
|
||||||
|
const price = getSupplyPrice(sc.selectedConsumableOrg, supplyId)
|
||||||
|
const supplies = organizationSupplies[sc.selectedConsumableOrg]
|
||||||
|
const supply = supplies?.find(s => s.id === supplyId)
|
||||||
|
return supply ? `${supply.name} (${price}₽)` : ''
|
||||||
|
}).join(', ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-blue-300 font-medium mt-1">
|
||||||
|
Итого доп. расходы: {formatCurrency(additionalCost)}
|
||||||
|
</div>
|
||||||
|
<div className="text-green-300 font-medium">
|
||||||
|
Полная стоимость за 1 шт: {formatCurrency((sc.customPrice / sc.selectedQuantity) + additionalCost)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-white/60 text-sm">Дата поставки:</label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={sc.deliveryDate}
|
||||||
|
onChange={(e) => updateCardSelection(sc.card, 'deliveryDate', e.target.value)}
|
||||||
|
className="bg-white/5 border-white/20 text-white mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<span className="text-white/60">Рынок:</span>
|
{/* Услуги */}
|
||||||
<span className="text-white ml-2">{markets.find(m => m.value === sc.selectedMarket)?.label || 'Не выбран'}</span>
|
<div className="space-y-3">
|
||||||
</div>
|
<div>
|
||||||
<div>
|
<label className="text-white/60 text-sm">Фулфилмент организация:</label>
|
||||||
<span className="text-white/60">Место:</span>
|
<Select
|
||||||
<span className="text-white ml-2">{sc.selectedPlace || 'Не указано'}</span>
|
value={sc.selectedFulfillmentOrg}
|
||||||
</div>
|
onValueChange={(value) => {
|
||||||
<div>
|
updateCardSelection(sc.card, 'selectedFulfillmentOrg', value)
|
||||||
<span className="text-white/60">Продавец:</span>
|
updateCardSelection(sc.card, 'selectedFulfillmentServices', []) // Сбрасываем услуги
|
||||||
<span className="text-white ml-2">{sc.sellerName || 'Не указан'}</span>
|
if (value) {
|
||||||
</div>
|
loadOrganizationServices(value) // Автоматически загружаем услуги
|
||||||
<div>
|
}
|
||||||
<span className="text-white/60">Телефон:</span>
|
}}
|
||||||
<span className="text-white ml-2">{sc.sellerPhone || 'Не указан'}</span>
|
>
|
||||||
</div>
|
<SelectTrigger className="bg-white/5 border-white/20 text-white mt-1">
|
||||||
<div>
|
<SelectValue placeholder="Выберите фулфилмент" />
|
||||||
<span className="text-white/60">Дата поставки:</span>
|
</SelectTrigger>
|
||||||
<span className="text-white ml-2">{sc.deliveryDate || 'Не выбрана'}</span>
|
<SelectContent>
|
||||||
|
{fulfillmentOrgs.map((org: Organization) => (
|
||||||
|
<SelectItem key={org.id} value={org.id}>
|
||||||
|
{org.name || org.fullName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sc.selectedFulfillmentOrg && (
|
||||||
|
<div>
|
||||||
|
<label className="text-white/60 text-sm">Услуги фулфилмента:</label>
|
||||||
|
<div className="mt-2 space-y-2 max-h-32 overflow-y-auto bg-white/5 border border-white/20 rounded p-2">
|
||||||
|
{organizationServices[sc.selectedFulfillmentOrg] ? (
|
||||||
|
organizationServices[sc.selectedFulfillmentOrg].length > 0 ? (
|
||||||
|
organizationServices[sc.selectedFulfillmentOrg].map((service) => {
|
||||||
|
const isSelected = sc.selectedFulfillmentServices.includes(service.id)
|
||||||
|
return (
|
||||||
|
<label key={service.id} className="flex items-center space-x-2 cursor-pointer hover:bg-white/5 p-1 rounded">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newServices = e.target.checked
|
||||||
|
? [...sc.selectedFulfillmentServices, service.id]
|
||||||
|
: sc.selectedFulfillmentServices.filter(id => id !== service.id)
|
||||||
|
updateCardSelection(sc.card, 'selectedFulfillmentServices', newServices)
|
||||||
|
}}
|
||||||
|
className="rounded border-white/20 bg-white/10 text-purple-500 focus:ring-purple-500"
|
||||||
|
/>
|
||||||
|
<span className="text-white text-sm">
|
||||||
|
{service.name} - {service.price} ₽
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div className="text-white/60 text-sm text-center py-2">
|
||||||
|
У данной организации нет услуг
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="text-white/60 text-sm text-center py-2">
|
||||||
|
Загрузка услуг...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-white/60 text-sm">Поставщик расходников:</label>
|
||||||
|
<Select
|
||||||
|
value={sc.selectedConsumableOrg}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
updateCardSelection(sc.card, 'selectedConsumableOrg', value)
|
||||||
|
updateCardSelection(sc.card, 'selectedConsumableServices', []) // Сбрасываем услуги
|
||||||
|
if (value) {
|
||||||
|
loadOrganizationSupplies(value) // Автоматически загружаем расходники
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="bg-white/5 border-white/20 text-white mt-1">
|
||||||
|
<SelectValue placeholder="Выберите поставщика" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{consumableOrgs.map((org: Organization) => (
|
||||||
|
<SelectItem key={org.id} value={org.id}>
|
||||||
|
{org.name || org.fullName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sc.selectedConsumableOrg && (
|
||||||
|
<div>
|
||||||
|
<label className="text-white/60 text-sm">Расходные материалы:</label>
|
||||||
|
<div className="mt-2 space-y-2 max-h-32 overflow-y-auto bg-white/5 border border-white/20 rounded p-2">
|
||||||
|
{organizationSupplies[sc.selectedConsumableOrg] ? (
|
||||||
|
organizationSupplies[sc.selectedConsumableOrg].length > 0 ? (
|
||||||
|
organizationSupplies[sc.selectedConsumableOrg].map((supply) => {
|
||||||
|
const isSelected = sc.selectedConsumableServices.includes(supply.id)
|
||||||
|
return (
|
||||||
|
<label key={supply.id} className="flex items-center space-x-2 cursor-pointer hover:bg-white/5 p-1 rounded">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newSupplies = e.target.checked
|
||||||
|
? [...sc.selectedConsumableServices, supply.id]
|
||||||
|
: sc.selectedConsumableServices.filter(id => id !== supply.id)
|
||||||
|
updateCardSelection(sc.card, 'selectedConsumableServices', newSupplies)
|
||||||
|
}}
|
||||||
|
className="rounded border-white/20 bg-white/10 text-green-500 focus:ring-green-500"
|
||||||
|
/>
|
||||||
|
<span className="text-white text-sm">
|
||||||
|
{supply.name} - {supply.price} ₽
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div className="text-white/60 text-sm text-center py-2">
|
||||||
|
У данной организации нет расходников
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="text-white/60 text-sm text-center py-2">
|
||||||
|
Загрузка расходников...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{sc.selectedServices.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<p className="text-white/60 text-sm mb-2">Услуги:</p>
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{sc.selectedServices.map(serviceId => {
|
|
||||||
const service = fulfillmentServices.find(s => s.id === serviceId)
|
|
||||||
return service ? (
|
|
||||||
<Badge key={serviceId} className="bg-purple-500/20 text-purple-300 border-purple-500/30 text-xs">
|
|
||||||
{service.name} ({formatCurrency(service.price)})
|
|
||||||
</Badge>
|
|
||||||
) : null
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<span className="text-white font-bold text-lg">{formatCurrency(totalPrice)}</span>
|
<span className="text-white font-bold text-lg">
|
||||||
|
{formatCurrency(sc.customPrice)}
|
||||||
|
</span>
|
||||||
|
{sc.selectedQuantity > 0 && sc.customPrice > 0 && (
|
||||||
|
<p className="text-white/60 text-sm">
|
||||||
|
~{formatCurrency(sc.customPrice / sc.selectedQuantity)} за шт.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -815,12 +1040,9 @@ export function WBProductCards({ onBack, onComplete }: WBProductCardsProps) {
|
|||||||
const selectedQuantity = getSelectedQuantity(card)
|
const selectedQuantity = getSelectedQuantity(card)
|
||||||
const isSelected = selectedQuantity > 0
|
const isSelected = selectedQuantity > 0
|
||||||
const selectedCard = selectedCards.find(sc => sc.card.nmID === card.nmID)
|
const selectedCard = selectedCards.find(sc => sc.card.nmID === card.nmID)
|
||||||
const mainSize = card.sizes[0]
|
|
||||||
const maxQuantity = mainSize?.quantity || 0
|
|
||||||
const price = mainSize?.discountedPrice || mainSize?.price || 0
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card key={card.nmID} className={`bg-white/10 backdrop-blur border-white/20 transition-all hover:scale-105 hover:shadow-2xl group ${isSelected ? 'ring-2 ring-purple-500/50 bg-purple-500/10' : ''}`}>
|
<Card key={card.nmID} className={`bg-white/10 backdrop-blur border-white/20 transition-all hover:scale-105 hover:shadow-2xl group ${isSelected ? 'ring-2 ring-purple-500/50 bg-purple-500/10' : ''} relative overflow-hidden`}>
|
||||||
<div className="p-3 space-y-3">
|
<div className="p-3 space-y-3">
|
||||||
{/* Изображение и основная информация */}
|
{/* Изображение и основная информация */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@ -833,13 +1055,23 @@ export function WBProductCards({ onBack, onComplete }: WBProductCardsProps) {
|
|||||||
onClick={() => handleCardClick(card)}
|
onClick={() => handleCardClick(card)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Количество в наличии */}
|
{/* Индикатор товара WB */}
|
||||||
<div className="absolute top-2 right-2">
|
<div className="absolute top-2 right-2">
|
||||||
<Badge className={`${maxQuantity > 10 ? 'bg-green-500/80' : maxQuantity > 0 ? 'bg-yellow-500/80' : 'bg-red-500/80'} text-white border-0 backdrop-blur text-xs`}>
|
<Badge className="bg-blue-500/90 text-white border-0 backdrop-blur text-xs font-medium px-2 py-1">
|
||||||
{maxQuantity}
|
◉ WB
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Индикатор выбранного товара */}
|
||||||
|
{isSelected && (
|
||||||
|
<div className="absolute top-2 left-2">
|
||||||
|
<Badge className="bg-gradient-to-r from-purple-500 to-pink-500 text-white border-0 text-xs font-medium">
|
||||||
|
<ShoppingCart className="h-3 w-3 mr-1" />
|
||||||
|
В корзине
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Overlay с кнопкой */}
|
{/* Overlay с кнопкой */}
|
||||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-center justify-center">
|
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-center justify-center">
|
||||||
<Button
|
<Button
|
||||||
@ -848,7 +1080,8 @@ export function WBProductCards({ onBack, onComplete }: WBProductCardsProps) {
|
|||||||
onClick={() => handleCardClick(card)}
|
onClick={() => handleCardClick(card)}
|
||||||
className="bg-white/20 backdrop-blur text-white border-white/30 hover:bg-white/30"
|
className="bg-white/20 backdrop-blur text-white border-white/30 hover:bg-white/30"
|
||||||
>
|
>
|
||||||
<Eye className="h-4 w-4" />
|
<Eye className="h-4 w-4 mr-2" />
|
||||||
|
Подробнее
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -857,80 +1090,78 @@ export function WBProductCards({ onBack, onComplete }: WBProductCardsProps) {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{/* Заголовок и бренд */}
|
{/* Заголовок и бренд */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<Badge className="bg-gray-500/20 text-gray-300 border-gray-500/30 text-xs">
|
<Badge className="bg-blue-500/20 text-blue-300 border-blue-500/30 text-xs font-medium">
|
||||||
{card.brand}
|
{card.brand}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
<span className="text-white/40 text-xs">№{card.nmID}</span>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-white font-semibold text-sm mb-1 line-clamp-2 leading-tight cursor-pointer hover:text-purple-300 transition-colors" onClick={() => handleCardClick(card)}>
|
<h3 className="text-white font-semibold text-sm mb-1 line-clamp-2 leading-tight cursor-pointer hover:text-purple-300 transition-colors" onClick={() => handleCardClick(card)}>
|
||||||
{card.title}
|
{card.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-white/60 text-xs mb-1">{card.vendorCode}</p>
|
<p className="text-white/60 text-xs mb-2">Артикул: {card.vendorCode}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Цена */}
|
{/* Информация о товаре */}
|
||||||
<div className="pt-1 border-t border-white/10">
|
<div className="pt-2 border-t border-white/10">
|
||||||
<div className="text-white font-bold text-base">
|
<div className="text-center">
|
||||||
{formatCurrency(price)}
|
<span className="text-white/60 text-xs">Добавьте в поставку для настройки</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Управление количеством */}
|
{/* Управление количеством */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2 bg-white/5 rounded-lg p-2">
|
||||||
<div className="flex items-center space-x-1">
|
<div className="flex items-center justify-center mb-1">
|
||||||
|
<span className="text-white/60 text-xs font-medium">Добавить в поставку:</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => updateCardSelection(card, 'selectedQuantity', Math.max(0, selectedQuantity - 1))}
|
onClick={() => updateCardSelection(card, 'selectedQuantity', Math.max(0, selectedQuantity - 1))}
|
||||||
disabled={selectedQuantity <= 0}
|
disabled={selectedQuantity <= 0}
|
||||||
className="h-7 w-7 p-0 text-white/60 hover:text-white hover:bg-white/10 border border-white/20"
|
className="h-8 w-8 p-0 text-white/60 hover:text-white hover:bg-white/10 border border-white/20 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<Minus className="h-3 w-3" />
|
<Minus className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
<input
|
<div className="flex-1">
|
||||||
type="text"
|
<input
|
||||||
inputMode="numeric"
|
type="text"
|
||||||
pattern="[0-9]*"
|
inputMode="numeric"
|
||||||
value={selectedQuantity}
|
pattern="[0-9]*"
|
||||||
onChange={(e) => {
|
value={selectedQuantity}
|
||||||
const value = e.target.value.replace(/[^0-9]/g, '')
|
onChange={(e) => {
|
||||||
const numValue = Math.max(0, Math.min(maxQuantity, parseInt(value) || 0))
|
const value = e.target.value.replace(/[^0-9]/g, '')
|
||||||
updateCardSelection(card, 'selectedQuantity', numValue)
|
const numValue = Math.max(0, parseInt(value) || 0)
|
||||||
}}
|
updateCardSelection(card, 'selectedQuantity', numValue)
|
||||||
onFocus={(e) => e.target.select()}
|
}}
|
||||||
className="h-7 w-12 text-center bg-white/10 border border-white/20 text-white text-sm rounded focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
onFocus={(e) => e.target.select()}
|
||||||
/>
|
className="h-8 w-full text-center bg-white/10 border border-white/20 text-white text-sm rounded focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => updateCardSelection(card, 'selectedQuantity', Math.min(maxQuantity, selectedQuantity + 1))}
|
onClick={() => updateCardSelection(card, 'selectedQuantity', selectedQuantity + 1)}
|
||||||
disabled={selectedQuantity >= maxQuantity}
|
className="h-8 w-8 p-0 text-white/60 hover:text-white hover:bg-white/10 border border-white/20"
|
||||||
className="h-7 w-7 p-0 text-white/60 hover:text-white hover:bg-white/10 border border-white/20"
|
|
||||||
>
|
>
|
||||||
<Plus className="h-3 w-3" />
|
<Plus className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{selectedQuantity > 0 && (
|
|
||||||
<Badge className="bg-gradient-to-r from-purple-500 to-pink-500 text-white border-0 text-xs ml-auto">
|
|
||||||
<ShoppingCart className="h-3 w-3 mr-1" />
|
|
||||||
{selectedQuantity}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Сумма для выбранного товара */}
|
{/* Указание что настройки в корзине */}
|
||||||
{selectedQuantity > 0 && (
|
{selectedQuantity > 0 && (
|
||||||
<div className="bg-gradient-to-r from-green-500/20 to-emerald-500/20 border border-green-500/30 rounded p-1">
|
<div className="bg-gradient-to-r from-blue-500/20 to-purple-500/20 border border-blue-500/30 rounded p-2 mt-2">
|
||||||
<div className="text-green-300 text-xs font-medium text-center">
|
<div className="text-center">
|
||||||
{formatCurrency(price * selectedQuantity)}
|
<span className="text-blue-300 text-xs">В корзине: {selectedQuantity} шт</span>
|
||||||
|
<p className="text-blue-200 text-xs mt-1">Настройте цену и услуги в корзине</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
@ -992,14 +1223,14 @@ export function WBProductCards({ onBack, onComplete }: WBProductCardsProps) {
|
|||||||
|
|
||||||
{/* Модальное окно с детальной информацией о товаре */}
|
{/* Модальное окно с детальной информацией о товаре */}
|
||||||
<Dialog open={!!selectedCardForDetails} onOpenChange={(open) => !open && closeDetailsModal()}>
|
<Dialog open={!!selectedCardForDetails} onOpenChange={(open) => !open && closeDetailsModal()}>
|
||||||
<DialogContent className="max-w-4xl max-h-[90vh] bg-black/90 backdrop-blur-xl border border-white/20 p-0">
|
<DialogContent className="max-w-6xl w-[95vw] max-h-[95vh] bg-black/95 backdrop-blur-xl border border-white/20 p-0 overflow-hidden">
|
||||||
<DialogHeader className="sr-only">
|
<DialogHeader className="sr-only">
|
||||||
<DialogTitle>Детальная информация о товаре</DialogTitle>
|
<DialogTitle>Детальная информация о товаре</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{selectedCardForDetails && (
|
{selectedCardForDetails && (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 p-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-0 h-full max-w-full">
|
||||||
{/* Изображения */}
|
{/* Изображения */}
|
||||||
<div className="space-y-4">
|
<div className="p-4 lg:p-6 space-y-4 overflow-hidden">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<img
|
<img
|
||||||
src={selectedCardForDetails.mediaFiles?.[currentImageIndex] || '/api/placeholder/400/400'}
|
src={selectedCardForDetails.mediaFiles?.[currentImageIndex] || '/api/placeholder/400/400'}
|
||||||
@ -1012,18 +1243,18 @@ export function WBProductCards({ onBack, onComplete }: WBProductCardsProps) {
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={prevImage}
|
onClick={prevImage}
|
||||||
className="absolute left-4 top-1/2 -translate-y-1/2 bg-black/50 hover:bg-black/70 text-white p-3 rounded-full"
|
className="absolute left-3 top-1/2 -translate-y-1/2 bg-black/70 hover:bg-black/90 text-white p-2 rounded-full transition-all"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="h-6 w-6" />
|
<ChevronLeft className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={nextImage}
|
onClick={nextImage}
|
||||||
className="absolute right-4 top-1/2 -translate-y-1/2 bg-black/50 hover:bg-black/70 text-white p-3 rounded-full"
|
className="absolute right-3 top-1/2 -translate-y-1/2 bg-black/70 hover:bg-black/90 text-white p-2 rounded-full transition-all"
|
||||||
>
|
>
|
||||||
<ChevronRight className="h-6 w-6" />
|
<ChevronRight className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 bg-black/50 px-3 py-1 rounded-full text-white text-sm">
|
<div className="absolute bottom-3 left-1/2 -translate-x-1/2 bg-black/70 px-3 py-1 rounded-full text-white text-sm">
|
||||||
{currentImageIndex + 1} из {selectedCardForDetails.mediaFiles?.length || 0}
|
{currentImageIndex + 1} из {selectedCardForDetails.mediaFiles?.length || 0}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@ -1031,107 +1262,114 @@ export function WBProductCards({ onBack, onComplete }: WBProductCardsProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Миниатюры изображений */}
|
{/* Миниатюры изображений */}
|
||||||
{selectedCardForDetails.mediaFiles?.length > 1 && (
|
{selectedCardForDetails.mediaFiles?.length > 1 && (
|
||||||
<div className="flex space-x-2 overflow-x-auto">
|
<div className="w-full overflow-hidden">
|
||||||
{selectedCardForDetails.mediaFiles?.map((image, index) => (
|
<div className="flex space-x-2 overflow-x-auto pb-2 scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent">
|
||||||
<img
|
{selectedCardForDetails.mediaFiles?.map((image, index) => (
|
||||||
key={index}
|
<img
|
||||||
src={image}
|
key={index}
|
||||||
alt={`${selectedCardForDetails.title} ${index + 1}`}
|
src={image}
|
||||||
className={`w-16 h-16 rounded-lg object-cover cursor-pointer flex-shrink-0 ${
|
alt={`${selectedCardForDetails.title} ${index + 1}`}
|
||||||
index === currentImageIndex ? 'ring-2 ring-purple-500' : 'opacity-60 hover:opacity-100'
|
className={`w-16 h-16 rounded-lg object-cover cursor-pointer flex-shrink-0 transition-all ${
|
||||||
}`}
|
index === currentImageIndex ? 'ring-2 ring-purple-500' : 'opacity-60 hover:opacity-100'
|
||||||
onClick={() => setCurrentImageIndex(index)}
|
}`}
|
||||||
/>
|
onClick={() => setCurrentImageIndex(index)}
|
||||||
))}
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Информация о товаре */}
|
{/* Информация о товаре */}
|
||||||
<div className="space-y-6">
|
<div className="p-4 lg:p-6 overflow-y-auto max-h-[95vh] space-y-4 max-w-full">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold text-white mb-2">{selectedCardForDetails.title}</h2>
|
<h2 className="text-xl font-bold text-white mb-2 leading-tight">{selectedCardForDetails.title}</h2>
|
||||||
<p className="text-white/60 mb-4">Артикул: {selectedCardForDetails.vendorCode}</p>
|
<p className="text-white/60 mb-3 text-sm">Артикул: {selectedCardForDetails.vendorCode}</p>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
<div className="grid grid-cols-1 gap-3 text-sm bg-white/5 rounded-lg p-4">
|
||||||
<div>
|
<div className="flex justify-between items-start gap-2">
|
||||||
<span className="text-white/60">Бренд:</span>
|
<span className="text-white/60 flex-shrink-0">Бренд:</span>
|
||||||
<span className="text-white ml-2">{selectedCardForDetails.brand}</span>
|
<span className="text-white font-medium text-right break-words">{selectedCardForDetails.brand}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex justify-between items-start gap-2">
|
||||||
<span className="text-white/60">Категория:</span>
|
<span className="text-white/60 flex-shrink-0">Категория:</span>
|
||||||
<span className="text-white ml-2">{selectedCardForDetails.object}</span>
|
<span className="text-white font-medium text-right break-words">{selectedCardForDetails.object}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex justify-between items-start gap-2">
|
||||||
<span className="text-white/60">Родительская категория:</span>
|
<span className="text-white/60 flex-shrink-0">Родительская:</span>
|
||||||
<span className="text-white ml-2">{selectedCardForDetails.parent}</span>
|
<span className="text-white font-medium text-right break-words">{selectedCardForDetails.parent}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex justify-between items-start gap-2">
|
||||||
<span className="text-white/60">Страна производства:</span>
|
<span className="text-white/60 flex-shrink-0">Страна:</span>
|
||||||
<span className="text-white ml-2">{selectedCardForDetails.countryProduction}</span>
|
<span className="text-white font-medium text-right break-words">{selectedCardForDetails.countryProduction}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedCardForDetails.description && (
|
{selectedCardForDetails.description && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-white font-semibold mb-2">Описание</h3>
|
<h3 className="text-white font-semibold mb-2 text-lg">Описание</h3>
|
||||||
<p className="text-white/80 text-sm leading-relaxed">{selectedCardForDetails.description}</p>
|
<div className="bg-white/5 rounded-lg p-4 max-h-40 overflow-y-auto scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent">
|
||||||
|
<p className="text-white/80 text-sm leading-relaxed whitespace-pre-wrap break-words">
|
||||||
|
{selectedCardForDetails.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Размеры и цены */}
|
{/* Размеры и цены */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-white font-semibold mb-3">Доступные варианты</h3>
|
<h3 className="text-white font-semibold mb-3 text-lg">Доступные варианты</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{selectedCardForDetails.sizes.map((size) => (
|
{selectedCardForDetails.sizes.map((size) => (
|
||||||
<div key={size.chrtID} className="bg-white/5 rounded-lg p-3">
|
<div key={size.chrtID} className="bg-white/5 border border-white/10 rounded-lg p-4">
|
||||||
<div className="flex justify-between items-center mb-2">
|
<div className="flex justify-between items-center mb-2">
|
||||||
<span className="text-white font-medium">{size.wbSize}</span>
|
<span className="text-white font-medium">{size.wbSize}</span>
|
||||||
<Badge className={`${size.quantity > 10 ? 'bg-green-500/20 text-green-300' : size.quantity > 0 ? 'bg-yellow-500/20 text-yellow-300' : 'bg-red-500/20 text-red-300'}`}>
|
<Badge className={`${size.quantity > 10 ? 'bg-green-500/30 text-green-200' : size.quantity > 0 ? 'bg-yellow-500/30 text-yellow-200' : 'bg-red-500/30 text-red-200'} font-medium`}>
|
||||||
{size.quantity} шт.
|
{size.quantity} шт.
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center text-sm">
|
<div className="flex justify-between items-center text-sm">
|
||||||
<span className="text-white/60">Размер: {size.techSize}</span>
|
<span className="text-white/60">Размер: {size.techSize}</span>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="text-white font-bold">{formatCurrency(size.discountedPrice || size.price)}</div>
|
<div className="text-white font-bold text-base">{formatCurrency(size.discountedPrice || size.price)}</div>
|
||||||
{size.discountedPrice && size.discountedPrice < size.price && (
|
{size.discountedPrice && size.discountedPrice < size.price && (
|
||||||
<div className="text-white/40 line-through text-xs">{formatCurrency(size.price)}</div>
|
<div className="text-white/40 line-through text-xs">{formatCurrency(size.price)}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Кнопки действий в модальном окне */}
|
{/* Кнопки действий в модальном окне */}
|
||||||
<div className="flex gap-3 pt-4 border-t border-white/20">
|
<div className="flex gap-3 pt-4 border-t border-white/20 sticky bottom-0 bg-black/50 backdrop-blur">
|
||||||
<Button
|
<Button
|
||||||
className="flex-1 bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 text-white"
|
className="flex-1 bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 text-white font-medium"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const currentQuantity = getSelectedQuantity(selectedCardForDetails)
|
const currentQuantity = getSelectedQuantity(selectedCardForDetails)
|
||||||
updateCardSelection(selectedCardForDetails, 'selectedQuantity', currentQuantity + 1)
|
updateCardSelection(selectedCardForDetails, 'selectedQuantity', currentQuantity + 1)
|
||||||
}}
|
closeDetailsModal()
|
||||||
>
|
}}
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
>
|
||||||
Добавить в корзину
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
</Button>
|
Добавить в корзину
|
||||||
<Button
|
</Button>
|
||||||
variant="outline"
|
<Button
|
||||||
className="border-white/20 text-white hover:bg-white/10"
|
variant="outline"
|
||||||
onClick={closeDetailsModal}
|
className="border-white/20 text-white hover:bg-white/10"
|
||||||
>
|
onClick={closeDetailsModal}
|
||||||
Закрыть
|
>
|
||||||
</Button>
|
Закрыть
|
||||||
</div>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</DialogContent>
|
)}
|
||||||
</Dialog>
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
@ -612,6 +612,39 @@ export const GET_MY_WILDBERRIES_SUPPLIES = gql`
|
|||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
// Запросы для получения услуг и расходников от конкретных организаций-контрагентов
|
||||||
|
export const GET_COUNTERPARTY_SERVICES = gql`
|
||||||
|
query GetCounterpartyServices($organizationId: ID!) {
|
||||||
|
counterpartyServices(organizationId: $organizationId) {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
description
|
||||||
|
price
|
||||||
|
imageUrl
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const GET_COUNTERPARTY_SUPPLIES = gql`
|
||||||
|
query GetCounterpartySupplies($organizationId: ID!) {
|
||||||
|
counterpartySupplies(organizationId: $organizationId) {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
description
|
||||||
|
price
|
||||||
|
quantity
|
||||||
|
unit
|
||||||
|
category
|
||||||
|
status
|
||||||
|
imageUrl
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
// Админ запросы
|
// Админ запросы
|
||||||
export const ADMIN_ME = gql`
|
export const ADMIN_ME = gql`
|
||||||
query AdminMe {
|
query AdminMe {
|
||||||
|
@ -810,6 +810,104 @@ export const resolvers = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Публичные услуги контрагента (для фулфилмента)
|
||||||
|
counterpartyServices: async (
|
||||||
|
_: unknown,
|
||||||
|
args: { organizationId: string },
|
||||||
|
context: Context
|
||||||
|
) => {
|
||||||
|
if (!context.user) {
|
||||||
|
throw new GraphQLError("Требуется авторизация", {
|
||||||
|
extensions: { code: "UNAUTHENTICATED" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUser = await prisma.user.findUnique({
|
||||||
|
where: { id: context.user.id },
|
||||||
|
include: { organization: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!currentUser?.organization) {
|
||||||
|
throw new GraphQLError("У пользователя нет организации");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, что запрашиваемая организация является контрагентом
|
||||||
|
const counterparty = await prisma.counterparty.findFirst({
|
||||||
|
where: {
|
||||||
|
organizationId: currentUser.organization.id,
|
||||||
|
counterpartyId: args.organizationId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!counterparty) {
|
||||||
|
throw new GraphQLError("Организация не является вашим контрагентом");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, что это фулфилмент центр
|
||||||
|
const targetOrganization = await prisma.organization.findUnique({
|
||||||
|
where: { id: args.organizationId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!targetOrganization || targetOrganization.type !== "FULFILLMENT") {
|
||||||
|
throw new GraphQLError("Услуги доступны только у фулфилмент центров");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await prisma.service.findMany({
|
||||||
|
where: { organizationId: args.organizationId },
|
||||||
|
include: { organization: true },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Публичные расходники контрагента (для оптовиков)
|
||||||
|
counterpartySupplies: async (
|
||||||
|
_: unknown,
|
||||||
|
args: { organizationId: string },
|
||||||
|
context: Context
|
||||||
|
) => {
|
||||||
|
if (!context.user) {
|
||||||
|
throw new GraphQLError("Требуется авторизация", {
|
||||||
|
extensions: { code: "UNAUTHENTICATED" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUser = await prisma.user.findUnique({
|
||||||
|
where: { id: context.user.id },
|
||||||
|
include: { organization: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!currentUser?.organization) {
|
||||||
|
throw new GraphQLError("У пользователя нет организации");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, что запрашиваемая организация является контрагентом
|
||||||
|
const counterparty = await prisma.counterparty.findFirst({
|
||||||
|
where: {
|
||||||
|
organizationId: currentUser.organization.id,
|
||||||
|
counterpartyId: args.organizationId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!counterparty) {
|
||||||
|
throw new GraphQLError("Организация не является вашим контрагентом");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, что это фулфилмент центр (у них есть расходники)
|
||||||
|
const targetOrganization = await prisma.organization.findUnique({
|
||||||
|
where: { id: args.organizationId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!targetOrganization || targetOrganization.type !== "FULFILLMENT") {
|
||||||
|
throw new GraphQLError("Расходники доступны только у фулфилмент центров");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await prisma.supply.findMany({
|
||||||
|
where: { organizationId: args.organizationId },
|
||||||
|
include: { organization: true },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
// Корзина пользователя
|
// Корзина пользователя
|
||||||
myCart: async (_: unknown, __: unknown, context: Context) => {
|
myCart: async (_: unknown, __: unknown, context: Context) => {
|
||||||
if (!context.user) {
|
if (!context.user) {
|
||||||
@ -4312,6 +4410,32 @@ export const resolvers = {
|
|||||||
where: { organizationId: parent.id },
|
where: { organizationId: parent.id },
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
services: async (parent: { id: string; services?: unknown[] }) => {
|
||||||
|
// Если услуги уже загружены через include, возвращаем их
|
||||||
|
if (parent.services) {
|
||||||
|
return parent.services;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Иначе загружаем отдельно
|
||||||
|
return await prisma.service.findMany({
|
||||||
|
where: { organizationId: parent.id },
|
||||||
|
include: { organization: true },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
supplies: async (parent: { id: string; supplies?: unknown[] }) => {
|
||||||
|
// Если расходники уже загружены через include, возвращаем их
|
||||||
|
if (parent.supplies) {
|
||||||
|
return parent.supplies;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Иначе загружаем отдельно
|
||||||
|
return await prisma.supply.findMany({
|
||||||
|
where: { organizationId: parent.id },
|
||||||
|
include: { organization: true },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
});
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
Cart: {
|
Cart: {
|
||||||
|
@ -66,6 +66,12 @@ export const typeDefs = gql`
|
|||||||
month: Int!
|
month: Int!
|
||||||
): [EmployeeSchedule!]!
|
): [EmployeeSchedule!]!
|
||||||
|
|
||||||
|
# Публичные услуги контрагента (для фулфилмента)
|
||||||
|
counterpartyServices(organizationId: ID!): [Service!]!
|
||||||
|
|
||||||
|
# Публичные расходники контрагента (для оптовиков)
|
||||||
|
counterpartySupplies(organizationId: ID!): [Supply!]!
|
||||||
|
|
||||||
# Админ запросы
|
# Админ запросы
|
||||||
adminMe: Admin
|
adminMe: Admin
|
||||||
allUsers(search: String, limit: Int, offset: Int): UsersResponse!
|
allUsers(search: String, limit: Int, offset: Int): UsersResponse!
|
||||||
@ -235,6 +241,8 @@ export const typeDefs = gql`
|
|||||||
emails: JSON
|
emails: JSON
|
||||||
users: [User!]!
|
users: [User!]!
|
||||||
apiKeys: [ApiKey!]!
|
apiKeys: [ApiKey!]!
|
||||||
|
services: [Service!]!
|
||||||
|
supplies: [Supply!]!
|
||||||
isCounterparty: Boolean
|
isCounterparty: Boolean
|
||||||
isCurrentUser: Boolean
|
isCurrentUser: Boolean
|
||||||
hasOutgoingRequest: Boolean
|
hasOutgoingRequest: Boolean
|
||||||
|
Reference in New Issue
Block a user