This commit is contained in:
Bivekich
2025-07-30 13:38:12 +03:00
parent 98595cc67c
commit 50c1ab0145
4 changed files with 532 additions and 269 deletions

View File

@ -1,37 +0,0 @@
#!/bin/bash
echo "🚀 Starting deployment to new.sferav.ru..."
# Остановка предыдущей версии
echo "⏹️ Stopping previous version..."
docker-compose -f docker-compose.prod.yml down
# Очистка неиспользуемых образов
echo "🧹 Cleaning up unused images..."
docker image prune -f
# Сборка и запуск новой версии
echo "🔨 Building and starting new version..."
docker-compose -f docker-compose.prod.yml up -d --build
# Ожидание запуска
echo "⏳ Waiting for application to start..."
sleep 10
# Проверка здоровья
echo "🏥 Checking application health..."
for i in {1..30}; do
if curl -f http://127.0.0.1:3017/api/health > /dev/null 2>&1; then
echo "✅ Application is healthy!"
break
fi
echo "⏳ Attempt $i/30 - waiting for health check..."
sleep 2
done
# Проверка статуса контейнера
echo "📊 Container status:"
docker-compose -f docker-compose.prod.yml ps
echo "🎉 Deployment completed!"
echo "🌐 Application is available at: https://new.sferav.ru"

View File

@ -1,36 +0,0 @@
services:
app:
build:
context: .
args:
- DATABASE_URL=${DATABASE_URL}
- SMS_AERO_EMAIL=${SMS_AERO_EMAIL}
- SMS_AERO_API_KEY=${SMS_AERO_API_KEY}
- SMS_AERO_API_URL=${SMS_AERO_API_URL}
- DADATA_API_KEY=${DADATA_API_KEY}
- DADATA_API_URL=${DADATA_API_URL}
- WILDBERRIES_API_URL=${WILDBERRIES_API_URL}
- OZON_API_URL=${OZON_API_URL}
- JWT_SECRET=${JWT_SECRET}
- SMS_DEV_MODE=${SMS_DEV_MODE}
ports:
- "127.0.0.1:3017:3000" # Привязка только к localhost
env_file:
- .env
environment:
- NODE_ENV=production
- PORT=3000
- HOSTNAME=0.0.0.0
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/health"]
timeout: 10s
interval: 30s
retries: 3
start_period: 40s
# Логирование
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"

View File

@ -463,6 +463,10 @@ export function AdvertisingTab({ selectedPeriod, useCustomDates, startDate, endD
const [expandedProducts, setExpandedProducts] = useState<Set<string>>(new Set()) const [expandedProducts, setExpandedProducts] = useState<Set<string>>(new Set())
const [expandedCampaigns, setExpandedCampaigns] = useState<Set<number>>(new Set()) const [expandedCampaigns, setExpandedCampaigns] = useState<Set<number>>(new Set())
// Состояния для фильтрации графика
const [showWbAds, setShowWbAds] = useState(true)
const [showExternalAds, setShowExternalAds] = useState(true)
// Состояние для формы добавления внешней рекламы // Состояние для формы добавления внешней рекламы
const [showAddForm, setShowAddForm] = useState<string | null>(null) const [showAddForm, setShowAddForm] = useState<string | null>(null)
const [newExternalAd, setNewExternalAd] = useState({ const [newExternalAd, setNewExternalAd] = useState({
@ -805,12 +809,13 @@ export function AdvertisingTab({ selectedPeriod, useCustomDates, startDate, endD
const result = Array.from(dailyMap.values()) const result = Array.from(dailyMap.values())
if (externalAdsData?.getExternalAds?.success && externalAdsData.getExternalAds.externalAds) { if (externalAdsData?.getExternalAds?.success && externalAdsData.getExternalAds.externalAds) {
// Сначала обрабатываем существующие дни
result.forEach(day => { result.forEach(day => {
const externalAdsForDay = externalAdsData.getExternalAds.externalAds.filter( const externalAdsForDay = externalAdsData.getExternalAds.externalAds.filter(
(ad: ExternalAd & { date: string; nmId: string }) => ad.date === day.date (ad: ExternalAd & { date: string; nmId: string }) => ad.date === day.date
) )
if (externalAdsForDay.length > 0 && day.products.length > 0) { if (externalAdsForDay.length > 0) {
// Группируем внешнюю рекламу по nmId товара // Группируем внешнюю рекламу по nmId товара
const adsByProduct = externalAdsForDay.reduce((acc: Record<string, ExternalAd[]>, ad: ExternalAd & { date: string; nmId: string }) => { const adsByProduct = externalAdsForDay.reduce((acc: Record<string, ExternalAd[]>, ad: ExternalAd & { date: string; nmId: string }) => {
if (!acc[ad.nmId]) acc[ad.nmId] = [] if (!acc[ad.nmId]) acc[ad.nmId] = []
@ -824,14 +829,88 @@ export function AdvertisingTab({ selectedPeriod, useCustomDates, startDate, endD
return acc return acc
}, {}) }, {})
// Добавляем внешнюю рекламу к соответствующим товарам // Добавляем внешнюю рекламу к соответствующим товарам или создаем новые товары
day.products.forEach(product => { Object.keys(adsByProduct).forEach(nmIdStr => {
if (adsByProduct[product.nmId.toString()]) { const nmId = parseInt(nmIdStr)
product.advertising.externalAds = adsByProduct[product.nmId.toString()] let existingProduct = day.products.find(p => p.nmId === nmId)
if (!existingProduct) {
// Создаем новый товар только с внешней рекламой
existingProduct = {
nmId: nmId,
name: `Товар ${nmId}`, // Будет обновлено при загрузке фотографий
totalViews: 0,
totalClicks: 0,
totalCost: 0,
totalOrders: 0,
totalRevenue: 0,
advertising: {
wbCampaigns: [],
externalAds: []
}
}
day.products.push(existingProduct)
} }
existingProduct.advertising.externalAds = adsByProduct[nmIdStr]
}) })
} }
}) })
// Теперь обрабатываем дни, которых нет в ВБ кампаниях, но есть внешняя реклама
const existingDates = new Set(result.map(day => day.date))
const externalAdsByDate = externalAdsData.getExternalAds.externalAds.reduce((acc: Record<string, Array<ExternalAd & { date: string; nmId: string }>>, ad: ExternalAd & { date: string; nmId: string }) => {
if (!acc[ad.date]) acc[ad.date] = []
acc[ad.date].push(ad)
return acc
}, {})
Object.keys(externalAdsByDate).forEach(dateStr => {
if (!existingDates.has(dateStr)) {
// Создаем новый день только с товарами, у которых есть внешняя реклама
const newDay: DailyAdvertisingData = {
date: dateStr,
totalSum: 0,
totalOrders: 0,
totalRevenue: 0,
products: []
}
// Группируем внешнюю рекламу по nmId товара
const adsByProduct = externalAdsByDate[dateStr].reduce((acc: Record<string, ExternalAd[]>, ad) => {
if (!acc[ad.nmId]) acc[ad.nmId] = []
acc[ad.nmId].push({
id: ad.id,
name: ad.name,
url: ad.url,
cost: ad.cost,
clicks: ad.clicks || 0
})
return acc
}, {})
// Создаем товары с внешней рекламой
Object.keys(adsByProduct).forEach(nmIdStr => {
const nmId = parseInt(nmIdStr)
const product: ProductData = {
nmId: nmId,
name: `Товар ${nmId}`, // Будет обновлено при загрузке фотографий
totalViews: 0,
totalClicks: 0,
totalCost: 0,
totalOrders: 0,
totalRevenue: 0,
advertising: {
wbCampaigns: [],
externalAds: adsByProduct[nmIdStr]
}
}
newDay.products.push(product)
})
result.push(newDay)
}
})
} }
// Обновляем общие суммы дня (ВБ реклама + внешняя реклама) // Обновляем общие суммы дня (ВБ реклама + внешняя реклама)
@ -929,13 +1008,12 @@ export function AdvertisingTab({ selectedPeriod, useCustomDates, startDate, endD
} }
// Обработчики для внешней рекламы // Обработчики для внешней рекламы
const handleAddExternalAd = async (date: string, ad: Omit<ExternalAd, 'id'>) => { const handleAddExternalAd = async (date: string, ad: Omit<ExternalAd, 'id'>, nmId?: string) => {
console.log('handleAddExternalAd called:', { date, ad }) console.log('handleAddExternalAd called:', { date, ad, nmId })
try { try {
// Находим nmId из первого товара дня (или можно передать отдельно) // Используем переданный nmId или находим из первого товара дня как fallback
const dayData = dailyData.find(d => d.date === date) const targetNmId = nmId || dailyData.find(d => d.date === date)?.products[0]?.nmId?.toString() || '0'
const nmId = dayData?.products[0]?.nmId?.toString() || '0'
await createExternalAd({ await createExternalAd({
variables: { variables: {
@ -944,12 +1022,12 @@ export function AdvertisingTab({ selectedPeriod, useCustomDates, startDate, endD
url: ad.url, url: ad.url,
cost: ad.cost, cost: ad.cost,
date: date, date: date,
nmId: nmId nmId: targetNmId
} }
} }
}) })
console.log('External ad created successfully') console.log('External ad created successfully for nmId:', targetNmId)
} catch (error) { } catch (error) {
console.error('Error creating external ad:', error) console.error('Error creating external ad:', error)
} }
@ -1237,6 +1315,34 @@ export function AdvertisingTab({ selectedPeriod, useCustomDates, startDate, endD
</div> </div>
</div> </div>
{/* Чекбоксы для переключения типов рекламы */}
<div className="flex items-center gap-4 mb-3 p-2 bg-white/5 rounded">
<div className="flex items-center gap-2">
<Checkbox
id="wb-ads"
checked={showWbAds}
onCheckedChange={setShowWbAds}
className="h-4 w-4"
/>
<Label htmlFor="wb-ads" className="text-xs text-white cursor-pointer flex items-center gap-1">
<div className="w-3 h-3 bg-blue-500 rounded"></div>
Реклама ВБ
</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="external-ads"
checked={showExternalAds}
onCheckedChange={setShowExternalAds}
className="h-4 w-4"
/>
<Label htmlFor="external-ads" className="text-xs text-white cursor-pointer flex items-center gap-1">
<div className="w-3 h-3 bg-pink-500 rounded"></div>
Внешняя реклама
</Label>
</div>
</div>
<div className="h-48"> <div className="h-48">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<BarChart data={spendingChartData}> <BarChart data={spendingChartData}>
@ -1273,20 +1379,24 @@ export function AdvertisingTab({ selectedPeriod, useCustomDates, startDate, endD
return null return null
}} }}
/> />
<Bar {showWbAds && (
dataKey="wbSum" <Bar
fill="#3b82f6" dataKey="wbSum"
name="ВБ реклама" fill="#3b82f6"
radius={[2, 2, 0, 0]} name="ВБ реклама"
opacity={0.8} radius={[2, 2, 0, 0]}
/> opacity={0.8}
<Bar />
dataKey="externalSum" )}
fill="#ec4899" {showExternalAds && (
name="Внешняя реклама" <Bar
radius={[2, 2, 0, 0]} dataKey="externalSum"
opacity={0.8} fill="#ec4899"
/> name="Внешняя реклама"
radius={[2, 2, 0, 0]}
opacity={0.8}
/>
)}
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>

View File

@ -1,23 +1,49 @@
"use client" "use client"
import React, { useState } from 'react' import React, { useState } from 'react'
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { useAuth } from '@/hooks/useAuth'
import { useQuery } from '@apollo/client'
import { GET_WB_WAREHOUSE_DATA } from '@/graphql/queries'
import { import {
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
Plus, Plus,
Trash2, Trash2,
Link, Link,
Package, Copy,
Eye, Eye,
MousePointer, MousePointer,
ShoppingCart, ShoppingCart,
DollarSign DollarSign,
Search,
Package
} from 'lucide-react' } from 'lucide-react'
interface WBStock {
nmId: number
vendorCode: string
title: string
brand: string
price: number
stocks: Array<{
warehouseId: number
warehouseName: string
quantity: number
quantityFull: number
inWayToClient: number
inWayFromClient: number
}>
totalQuantity: number
totalReserved: number
photos: any[]
mediaFiles: any[]
characteristics: any[]
subjectName: string
description: string
}
interface ExternalAd { interface ExternalAd {
id: string id: string
name: string name: string
@ -70,7 +96,7 @@ interface SimpleAdvertisingTableProps {
dailyData: DailyAdvertisingData[] dailyData: DailyAdvertisingData[]
productPhotos?: Map<number, string> productPhotos?: Map<number, string>
generatedLinksData?: Record<string, GeneratedLink[]> generatedLinksData?: Record<string, GeneratedLink[]>
onAddExternalAd?: (date: string, ad: Omit<ExternalAd, 'id'>) => void onAddExternalAd?: (date: string, ad: Omit<ExternalAd, 'id'>, nmId?: string) => void
onRemoveExternalAd?: (date: string, adId: string) => void onRemoveExternalAd?: (date: string, adId: string) => void
onUpdateExternalAd?: (date: string, adId: string, updates: Partial<ExternalAd>) => void onUpdateExternalAd?: (date: string, adId: string, updates: Partial<ExternalAd>) => void
onGenerateLink?: (date: string, adId: string, adName: string, adUrl: string) => void onGenerateLink?: (date: string, adId: string, adName: string, adUrl: string) => void
@ -85,16 +111,32 @@ export function SimpleAdvertisingTable({
onUpdateExternalAd, onUpdateExternalAd,
onGenerateLink onGenerateLink
}: SimpleAdvertisingTableProps) { }: SimpleAdvertisingTableProps) {
const [showWbAds, setShowWbAds] = useState(true) const { user } = useAuth()
const [showExternalAds, setShowExternalAds] = useState(true)
const [expandedDays, setExpandedDays] = useState<Set<string>>(new Set())
const [expandedProducts, setExpandedProducts] = useState<Set<string>>(new Set())
const [showAddForm, setShowAddForm] = useState<string | null>(null) const [showAddForm, setShowAddForm] = useState<string | null>(null)
const [showProductList, setShowProductList] = useState<string | null>(null)
const [searchTerm, setSearchTerm] = useState('')
const [filteredProducts, setFilteredProducts] = useState<WBStock[]>([])
const [newExternalAd, setNewExternalAd] = useState({ const [newExternalAd, setNewExternalAd] = useState({
name: '', name: '',
url: '', url: '',
cost: '' cost: ''
}) })
const [selectedProduct, setSelectedProduct] = useState<WBStock | null>(null)
// Получаем данные склада ВБ из кэша
const { data: warehouseData, loading: warehouseLoading, error: warehouseError } = useQuery(GET_WB_WAREHOUSE_DATA, {
skip: !user,
errorPolicy: 'all'
})
// Вычисляем общие итоги для результирующей строки
const totalWbCost = dailyData.reduce((sum, day) =>
sum + day.products.reduce((daySum, product) => daySum + product.totalCost, 0), 0)
const totalExternalCost = dailyData.reduce((sum, day) =>
sum + day.products.reduce((daySum, product) =>
daySum + product.advertising.externalAds.reduce((adSum, ad) => adSum + ad.cost, 0), 0), 0)
const totalCost = totalWbCost + totalExternalCost
const totalOrders = dailyData.reduce((sum, day) => sum + day.totalOrders, 0)
const formatCurrency = (value: number) => { const formatCurrency = (value: number) => {
if (value === 0) return '—' if (value === 0) return '—'
@ -113,66 +155,102 @@ export function SimpleAdvertisingTable({
return value > 0 ? new Intl.NumberFormat('ru-RU').format(value) : '—' return value > 0 ? new Intl.NumberFormat('ru-RU').format(value) : '—'
} }
const toggleDay = (date: string) => { const formatDate = (dateString: string) => {
const newExpanded = new Set(expandedDays) const date = new Date(dateString)
if (newExpanded.has(date)) { return date.toLocaleDateString('ru-RU', {
newExpanded.delete(date) day: '2-digit',
} else { month: '2-digit',
newExpanded.add(date) year: 'numeric'
} })
setExpandedDays(newExpanded)
} }
const toggleProduct = (date: string, nmId: number) => {
const key = `${date}-${nmId}`
const newExpanded = new Set(expandedProducts)
if (newExpanded.has(key)) {
newExpanded.delete(key)
} else {
newExpanded.add(key)
}
setExpandedProducts(newExpanded)
}
const handleAddExternalAdLocal = (date: string, nmId: number) => { const handleAddExternalAdLocal = (productKey: string, date: string, nmId?: string) => {
if (newExternalAd.name && newExternalAd.url && newExternalAd.cost && onAddExternalAd) { if (newExternalAd.name && newExternalAd.url && newExternalAd.cost && onAddExternalAd) {
console.log('Creating external ad for:', { productKey, date, nmId, selectedProduct })
onAddExternalAd(date, { onAddExternalAd(date, {
name: newExternalAd.name, name: newExternalAd.name,
url: newExternalAd.url, url: newExternalAd.url,
cost: parseFloat(newExternalAd.cost) || 0 cost: parseFloat(newExternalAd.cost) || 0
}) }, nmId)
setNewExternalAd({ name: '', url: '', cost: '' }) setNewExternalAd({ name: '', url: '', cost: '' })
setShowAddForm(null) setShowAddForm(null)
setSelectedProduct(null)
setShowProductList(null)
} }
} }
// Получаем все товары из кэша ВБ
const getAllProducts = (): WBStock[] => {
if (!warehouseData?.getWBWarehouseData?.success || !warehouseData.getWBWarehouseData.cache?.data) {
return []
}
try {
const parsedData = typeof warehouseData.getWBWarehouseData.cache.data === 'string'
? JSON.parse(warehouseData.getWBWarehouseData.cache.data)
: warehouseData.getWBWarehouseData.cache.data
return parsedData.stocks || []
} catch (error) {
console.error('Error parsing warehouse data:', error)
return []
}
}
const filterProducts = (term: string) => {
const allProducts = getAllProducts()
if (!term.trim()) {
setFilteredProducts(allProducts)
return
}
const filtered = allProducts.filter(product =>
product.title.toLowerCase().includes(term.toLowerCase()) ||
product.brand.toLowerCase().includes(term.toLowerCase()) ||
product.vendorCode.toLowerCase().includes(term.toLowerCase()) ||
product.subjectName?.toLowerCase().includes(term.toLowerCase()) ||
product.nmId.toString().includes(term)
)
setFilteredProducts(filtered)
}
const handleProductSelect = (product: WBStock, date: string) => {
setSelectedProduct(product)
setShowProductList(null)
setShowAddForm(`new-product-${date}-${product.nmId}`)
}
const handleShowProductList = (date: string) => {
setShowProductList(date)
const allProducts = getAllProducts()
setFilteredProducts(allProducts)
}
const getProductImage = (product: WBStock) => {
// Генерируем fallback URL как в оригинальном компоненте
const fallbackUrl = `https://basket-${String(product.nmId).slice(0, 2)}.wbbasket.ru/vol${String(product.nmId).slice(0, -5)}/part${String(product.nmId).slice(0, -3)}/${product.nmId}/images/big/1.webp`
// Проверяем photos
if (product.photos && product.photos.length > 0) {
const photo = product.photos[0] as any
return photo?.big || photo?.c516x688 || photo?.c246x328 || photo?.square || photo?.tm || fallbackUrl
}
// Проверяем mediaFiles
if (product.mediaFiles && product.mediaFiles.length > 0) {
const media = product.mediaFiles[0] as any
return media?.big || media?.c516x688 || media?.c246x328 || media?.square || media?.tm || fallbackUrl
}
return fallbackUrl
}
return ( return (
<div className="bg-white/5 rounded-lg border border-white/10 overflow-hidden"> <div className="bg-white/5 rounded-lg border border-white/10 overflow-hidden">
{/* Фильтры */} {/* Заголовок таблицы */}
<div className="p-4 border-b border-white/10">
<div className="flex gap-6">
<div className="flex items-center gap-2">
<Checkbox
id="wb-ads"
checked={showWbAds}
onCheckedChange={(checked) => setShowWbAds(checked === true)}
className="border-white/30"
/>
<Label htmlFor="wb-ads" className="text-white/80 text-sm">Реклама ВБ</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="external-ads"
checked={showExternalAds}
onCheckedChange={(checked) => setShowExternalAds(checked === true)}
className="border-white/30"
/>
<Label htmlFor="external-ads" className="text-white/80 text-sm">Реклама внешняя</Label>
</div>
</div>
</div>
{/* Заголовок таблицы - как в Figma */}
<div className="bg-purple-600/20 border-b border-white/10"> <div className="bg-purple-600/20 border-b border-white/10">
<div className="grid grid-cols-5 gap-4 p-3 text-white text-sm font-medium"> <div className="grid grid-cols-5 gap-4 p-3 text-white text-sm font-medium">
<div>Дата</div> <div>Дата</div>
@ -183,7 +261,18 @@ export function SimpleAdvertisingTable({
</div> </div>
</div> </div>
{/* Строки таблицы с раскрывающимся содержимым */} {/* Результирующая строка под шапкой */}
<div className="bg-green-600/20 border-b border-white/10">
<div className="grid grid-cols-5 gap-4 p-3 text-white text-sm font-bold">
<div>ИТОГО</div>
<div className="text-center">{formatCurrency(totalCost)}</div>
<div className="text-center">{totalOrders}</div>
<div className="text-center">{formatCurrency(totalWbCost)}</div>
<div className="text-center">{formatCurrency(totalExternalCost)}</div>
</div>
</div>
{/* Строки таблицы - 2 уровня: день -> товары (всегда открыто) */}
<div className="max-h-96 overflow-y-auto"> <div className="max-h-96 overflow-y-auto">
{dailyData.map((day) => { {dailyData.map((day) => {
const dayWbCost = day.products.reduce((sum, product) => sum + product.totalCost, 0) const dayWbCost = day.products.reduce((sum, product) => sum + product.totalCost, 0)
@ -191,100 +280,80 @@ export function SimpleAdvertisingTable({
sum + product.advertising.externalAds.reduce((adSum, ad) => adSum + ad.cost, 0), 0) sum + product.advertising.externalAds.reduce((adSum, ad) => adSum + ad.cost, 0), 0)
const dayTotalCost = dayWbCost + dayExternalCost const dayTotalCost = dayWbCost + dayExternalCost
const dayOrders = day.totalOrders const dayOrders = day.totalOrders
const isExpanded = expandedDays.has(day.date)
return ( return (
<div key={day.date}> <div key={day.date}>
{/* Основная строка дня - как в Figma */} {/* Основная строка дня */}
<div <div className="grid grid-cols-5 gap-4 p-3 border-b border-white/5 bg-white/5 text-white/80 text-sm font-medium">
className="grid grid-cols-5 gap-4 p-3 border-b border-white/5 hover:bg-white/5 text-white/80 text-sm cursor-pointer"
onClick={() => toggleDay(day.date)}
>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />} <span className="font-bold">{formatDate(day.date)}</span>
<span className="font-medium">{day.date}</span>
</div> </div>
<div className="text-center">{formatCurrency(dayTotalCost)}</div> <div className="text-center">{formatCurrency(dayTotalCost)}</div>
<div className="text-center">{dayOrders}</div> <div className="text-center">{dayOrders}</div>
<div className="text-center"> <div className="text-center">{formatCurrency(dayWbCost)}</div>
{showWbAds ? formatCurrency(dayWbCost) : '—'} <div className="text-center">{formatCurrency(dayExternalCost)}</div>
</div>
<div className="text-center">
{showExternalAds ? formatCurrency(dayExternalCost) : '—'}
</div>
</div> </div>
{/* Раскрывающееся содержимое с товарами */} {/* Товары всегда видны - второй уровень */}
{isExpanded && ( <div className="border-b border-white/5">
<div className="bg-white/2 border-b border-white/5"> {day.products.map((product) => {
{day.products.map((product) => {
const productKey = `${day.date}-${product.nmId}` const productKey = `${day.date}-${product.nmId}`
const isProductExpanded = expandedProducts.has(productKey)
const productExternalCost = product.advertising.externalAds.reduce((sum, ad) => sum + ad.cost, 0) const productExternalCost = product.advertising.externalAds.reduce((sum, ad) => sum + ad.cost, 0)
const productTotalCost = product.totalCost + productExternalCost
return ( return (
<div key={productKey} className="ml-6"> <div key={productKey} className="ml-4">
{/* Строка товара */} {/* Строка товара с многострочными ячейками */}
<div <div className="grid grid-cols-5 gap-4 p-3 border-b border-white/5 text-white/70 text-sm">
className="grid grid-cols-5 gap-4 p-3 border-b border-white/5 hover:bg-white/5 text-white/70 text-sm cursor-pointer" {/* Карточка товара */}
onClick={() => toggleProduct(day.date, product.nmId)} <div className="flex items-start gap-2">
> {productPhotos.has(product.nmId) && (
<div className="flex items-center gap-2"> <img
{isProductExpanded ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />} src={productPhotos.get(product.nmId)}
<div className="flex items-center gap-2"> alt={product.name}
{productPhotos.has(product.nmId) && ( className="w-8 h-8 rounded object-cover flex-shrink-0"
<img />
src={productPhotos.get(product.nmId)} )}
alt={product.name} <div>
className="w-6 h-6 rounded object-cover" <div className="text-white/90 text-xs font-medium">{product.name}</div>
/> <div className="text-white/60 text-xs">#{product.nmId}</div>
)}
<div>
<div className="text-white/90 text-xs font-medium truncate max-w-32">{product.name}</div>
<div className="text-white/60 text-xs">#{product.nmId}</div>
</div>
</div> </div>
</div> </div>
<div className="text-center">{formatCurrency(product.totalCost + productExternalCost)}</div>
<div className="text-center">{product.totalOrders}</div>
<div className="text-center">
{showWbAds ? formatCurrency(product.totalCost) : '—'}
</div>
<div className="text-center">
{showExternalAds ? formatCurrency(productExternalCost) : '—'}
</div>
</div>
{/* Раскрывающееся содержимое товара с кампаниями */} {/* Общая сумма */}
{isProductExpanded && ( <div className="text-center self-start">{formatCurrency(productTotalCost)}</div>
<div className="ml-6 bg-white/1 border-b border-white/5">
{/* ВБ кампании */} {/* Заказы */}
{showWbAds && product.advertising.wbCampaigns.map((campaign) => ( <div className="text-center self-start">{product.totalOrders}</div>
<div key={campaign.campaignId} className="grid grid-cols-5 gap-4 p-2 text-white/60 text-xs">
<div className="flex items-center gap-1"> {/* Реклама ВБ - многострочная ячейка */}
<Eye className="h-3 w-3 text-blue-400" /> <div className="text-center">
<span>ВБ #{campaign.campaignId}</span> {product.advertising.wbCampaigns.length > 0 ? (
</div> <div className="space-y-1">
<div className="text-center">{formatCurrency(campaign.cost)}</div> {product.advertising.wbCampaigns.map((campaign, index) => (
<div className="text-center">{campaign.orders}</div> <div key={campaign.campaignId} className="text-xs bg-blue-500/10 rounded p-1">
<div className="text-center">{formatCurrency(campaign.cost)}</div> <div className="text-blue-400 font-medium">
<div className="text-center"></div> {index === 0 ? 'Авто' : index === 1 ? 'Фразы' : index === 2 ? 'Предмет' : `Тип ${campaign.campaignId}`}
</div>
<div className="text-white/80">{formatCurrency(campaign.cost)}</div>
<div className="text-white/60">{campaign.orders} зак.</div>
</div>
))}
</div> </div>
))} ) : (
<span className="text-white/40"></span>
)}
</div>
{/* Внешняя реклама */} {/* Реклама внешняя - многострочная ячейка с кнопками */}
{showExternalAds && product.advertising.externalAds.map((ad) => ( <div className="text-center">
<div key={ad.id} className="grid grid-cols-5 gap-4 p-2 text-white/60 text-xs"> <div className="space-y-1">
<div className="flex items-center gap-1"> {product.advertising.externalAds.map((ad) => (
<MousePointer className="h-3 w-3 text-purple-400" /> <div key={ad.id} className="text-xs bg-purple-500/10 rounded p-1">
<span className="truncate max-w-24">{ad.name}</span> <div className="text-purple-400 font-medium truncate">{ad.name}</div>
</div> <div className="text-white/80">{formatCurrency(ad.cost)}</div>
<div className="text-center">{formatCurrency(ad.cost)}</div> <div className="text-white/60">{ad.clicks || 0} кликов</div>
<div className="text-center"></div> <div className="flex gap-1 justify-center mt-1">
<div className="text-center"></div>
<div className="text-center flex items-center justify-center gap-1">
{formatCurrency(ad.cost)}
<div className="flex gap-1">
{onGenerateLink && ( {onGenerateLink && (
<Button <Button
size="sm" size="sm"
@ -293,9 +362,10 @@ export function SimpleAdvertisingTable({
e.stopPropagation() e.stopPropagation()
onGenerateLink(day.date, ad.id, ad.name, ad.url) onGenerateLink(day.date, ad.id, ad.name, ad.url)
}} }}
className="h-4 w-4 p-0 text-blue-400 hover:bg-blue-500/20" className="h-5 w-5 p-0 text-blue-400 hover:bg-blue-500/20"
title="Скопировать ссылку"
> >
<Link className="h-2 w-2" /> <Copy className="h-3 w-3" />
</Button> </Button>
)} )}
{onRemoveExternalAd && ( {onRemoveExternalAd && (
@ -306,22 +376,21 @@ export function SimpleAdvertisingTable({
e.stopPropagation() e.stopPropagation()
onRemoveExternalAd(day.date, ad.id) onRemoveExternalAd(day.date, ad.id)
}} }}
className="h-4 w-4 p-0 text-red-400 hover:bg-red-500/20" className="h-5 w-5 p-0 text-red-400 hover:bg-red-500/20"
title="Удалить"
> >
<Trash2 className="h-2 w-2" /> <Trash2 className="h-3 w-3" />
</Button> </Button>
)} )}
</div> </div>
</div> </div>
</div> ))}
))}
{/* Кнопка добавления внешней рекламы */} {/* Инлайн форма добавления внешней рекламы */}
{onAddExternalAd && ( {onAddExternalAd && (
<div className="grid grid-cols-5 gap-4 p-2"> <div>
<div className="col-span-5">
{showAddForm === productKey ? ( {showAddForm === productKey ? (
<div className="flex gap-2 items-center"> <div className="bg-white/5 rounded p-2 space-y-2">
<Input <Input
placeholder="Название" placeholder="Название"
value={newExternalAd.name} value={newExternalAd.name}
@ -339,44 +408,201 @@ export function SimpleAdvertisingTable({
type="number" type="number"
value={newExternalAd.cost} value={newExternalAd.cost}
onChange={(e) => setNewExternalAd(prev => ({ ...prev, cost: e.target.value }))} onChange={(e) => setNewExternalAd(prev => ({ ...prev, cost: e.target.value }))}
className="h-6 w-20 bg-white/10 border-white/20 text-white text-xs" className="h-6 bg-white/10 border-white/20 text-white text-xs"
/> />
<Button <div className="flex gap-1">
size="sm" <Button
onClick={() => handleAddExternalAdLocal(day.date, product.nmId)} size="sm"
className="h-6 bg-green-600 hover:bg-green-700 text-white text-xs px-2" onClick={() => handleAddExternalAdLocal(productKey, day.date)}
> className="h-6 bg-green-600 hover:bg-green-700 text-white text-xs px-2 flex-1"
Добавить >
</Button> Добавить
<Button </Button>
size="sm" <Button
onClick={() => setShowAddForm(null)} size="sm"
variant="ghost" onClick={() => setShowAddForm(null)}
className="h-6 text-white/60 hover:bg-white/10 text-xs px-2" variant="ghost"
> className="h-6 text-white/60 hover:bg-white/10 text-xs px-2"
Отмена >
</Button> ×
</Button>
</div>
</div> </div>
) : ( ) : (
<Button <button
size="sm"
onClick={() => setShowAddForm(productKey)} onClick={() => setShowAddForm(productKey)}
className="h-6 px-2 bg-purple-600 hover:bg-purple-700 text-white text-xs" className="w-full h-6 px-2 bg-gradient-to-r from-purple-500/20 to-purple-600/20 border border-purple-500/30 rounded text-purple-300 hover:from-purple-500/30 hover:to-purple-600/30 hover:border-purple-400/50 hover:text-purple-200 transition-all duration-200 text-xs flex items-center justify-center gap-1 backdrop-blur-sm"
> >
<Plus className="h-2 w-2 mr-1" /> <Plus className="h-3 w-3" />
Добавить внешнюю рекламу Добавить
</Button> </button>
)} )}
</div> </div>
</div> )}
)} </div>
</div> </div>
)} </div>
</div> </div>
) )
})} })}
</div>
)} {/* Кнопка добавления рекламы для нового товара */}
{onAddExternalAd && (
<div className="ml-4 p-3 border-b border-white/5">
{showProductList === day.date ? (
<div className="bg-white/5 rounded p-3 space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-white font-medium text-sm">Выберите товар для рекламы</h4>
<Button
size="sm"
onClick={() => {
setShowProductList(null)
setSearchTerm('')
}}
variant="ghost"
className="text-white/60 hover:bg-white/10 h-6 w-6 p-0"
>
×
</Button>
</div>
{/* Поиск среди загруженных товаров */}
<div className="flex items-center gap-2">
<Search className="h-4 w-4 text-white/60" />
<Input
placeholder="Поиск среди товаров..."
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value)
filterProducts(e.target.value)
}}
className="bg-white/10 border-white/20 text-white text-sm"
/>
</div>
{warehouseLoading ? (
<div className="text-center text-white/60 text-sm py-4">
Загрузка товаров...
</div>
) : warehouseError ? (
<div className="text-center text-red-400 text-sm py-4">
Ошибка загрузки товаров: {warehouseError.message}
</div>
) : filteredProducts.length > 0 ? (
<div className="max-h-64 overflow-y-auto space-y-1">
{filteredProducts.map((product) => (
<div
key={product.nmId}
onClick={() => handleProductSelect(product, day.date)}
className="flex items-center gap-3 p-3 bg-white/5 rounded hover:bg-white/10 cursor-pointer transition-colors border border-white/10"
>
{getProductImage(product) && (
<img
src={getProductImage(product)!}
alt={product.title}
className="w-12 h-12 rounded object-cover flex-shrink-0"
/>
)}
<div className="flex-1 min-w-0">
<div className="text-white text-sm font-medium truncate mb-1">
{product.title}
</div>
<div className="text-white/60 text-xs space-y-0.5">
<div>{product.brand} #{product.nmId}</div>
<div>Артикул: {product.vendorCode}</div>
<div className="text-green-400">На складе: {product.totalQuantity} шт.</div>
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-center text-white/60 text-sm py-4">
{searchTerm ? 'Товары не найдены' : 'Нет доступных товаров'}
</div>
)}
</div>
) : showAddForm?.startsWith(`new-product-${day.date}`) ? (
<div className="bg-white/5 rounded p-3 space-y-3">
{selectedProduct && (
<div className="flex items-center gap-3 p-3 bg-white/10 rounded border border-white/20">
{getProductImage(selectedProduct) && (
<img
src={getProductImage(selectedProduct)!}
alt={selectedProduct.title}
className="w-12 h-12 rounded object-cover flex-shrink-0"
/>
)}
<div className="flex-1 min-w-0">
<div className="text-white text-sm font-medium truncate mb-1">
{selectedProduct.title}
</div>
<div className="text-white/60 text-xs space-y-0.5">
<div>{selectedProduct.brand} #{selectedProduct.nmId}</div>
<div>Артикул: {selectedProduct.vendorCode}</div>
</div>
</div>
</div>
)}
<Input
placeholder="Название рекламы"
value={newExternalAd.name}
onChange={(e) => setNewExternalAd(prev => ({ ...prev, name: e.target.value }))}
className="bg-white/10 border-white/20 text-white text-sm"
/>
<Input
placeholder="URL рекламы"
value={newExternalAd.url}
onChange={(e) => setNewExternalAd(prev => ({ ...prev, url: e.target.value }))}
className="bg-white/10 border-white/20 text-white text-sm"
/>
<Input
placeholder="Стоимость"
type="number"
value={newExternalAd.cost}
onChange={(e) => setNewExternalAd(prev => ({ ...prev, cost: e.target.value }))}
className="bg-white/10 border-white/20 text-white text-sm"
/>
<div className="flex gap-2">
<Button
size="sm"
onClick={() => selectedProduct && handleAddExternalAdLocal(
`new-product-${day.date}-${selectedProduct.nmId}`,
day.date,
selectedProduct.nmId.toString()
)}
className="bg-green-600 hover:bg-green-700 text-white text-sm flex-1"
>
Создать рекламу
</Button>
<Button
size="sm"
onClick={() => {
setShowAddForm(null)
setSelectedProduct(null)
setNewExternalAd({ name: '', url: '', cost: '' })
}}
variant="ghost"
className="text-white/60 hover:bg-white/10 text-sm"
>
Отмена
</Button>
</div>
</div>
) : (
<button
onClick={() => handleShowProductList(day.date)}
className="w-full h-8 px-3 bg-gradient-to-r from-green-500/20 to-green-600/20 border border-green-500/30 rounded text-green-300 hover:from-green-500/30 hover:to-green-600/30 hover:border-green-400/50 hover:text-green-200 transition-all duration-200 text-sm flex items-center justify-center gap-2 backdrop-blur-sm"
>
<Package className="h-4 w-4" />
Добавить рекламу для товара
</button>
)}
</div>
)}
</div>
</div> </div>
) )
})} })}