advrt
This commit is contained in:
37
deploy.sh
37
deploy.sh
@ -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"
|
@ -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"
|
@ -463,6 +463,10 @@ export function AdvertisingTab({ selectedPeriod, useCustomDates, startDate, endD
|
||||
const [expandedProducts, setExpandedProducts] = useState<Set<string>>(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 [newExternalAd, setNewExternalAd] = useState({
|
||||
@ -805,12 +809,13 @@ export function AdvertisingTab({ selectedPeriod, useCustomDates, startDate, endD
|
||||
const result = Array.from(dailyMap.values())
|
||||
|
||||
if (externalAdsData?.getExternalAds?.success && externalAdsData.getExternalAds.externalAds) {
|
||||
// Сначала обрабатываем существующие дни
|
||||
result.forEach(day => {
|
||||
const externalAdsForDay = externalAdsData.getExternalAds.externalAds.filter(
|
||||
(ad: ExternalAd & { date: string; nmId: string }) => ad.date === day.date
|
||||
)
|
||||
|
||||
if (externalAdsForDay.length > 0 && day.products.length > 0) {
|
||||
if (externalAdsForDay.length > 0) {
|
||||
// Группируем внешнюю рекламу по nmId товара
|
||||
const adsByProduct = externalAdsForDay.reduce((acc: Record<string, ExternalAd[]>, ad: ExternalAd & { date: string; nmId: string }) => {
|
||||
if (!acc[ad.nmId]) acc[ad.nmId] = []
|
||||
@ -824,14 +829,88 @@ export function AdvertisingTab({ selectedPeriod, useCustomDates, startDate, endD
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
// Добавляем внешнюю рекламу к соответствующим товарам
|
||||
day.products.forEach(product => {
|
||||
if (adsByProduct[product.nmId.toString()]) {
|
||||
product.advertising.externalAds = adsByProduct[product.nmId.toString()]
|
||||
// Добавляем внешнюю рекламу к соответствующим товарам или создаем новые товары
|
||||
Object.keys(adsByProduct).forEach(nmIdStr => {
|
||||
const nmId = parseInt(nmIdStr)
|
||||
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'>) => {
|
||||
console.log('handleAddExternalAd called:', { date, ad })
|
||||
const handleAddExternalAd = async (date: string, ad: Omit<ExternalAd, 'id'>, nmId?: string) => {
|
||||
console.log('handleAddExternalAd called:', { date, ad, nmId })
|
||||
|
||||
try {
|
||||
// Находим nmId из первого товара дня (или можно передать отдельно)
|
||||
const dayData = dailyData.find(d => d.date === date)
|
||||
const nmId = dayData?.products[0]?.nmId?.toString() || '0'
|
||||
// Используем переданный nmId или находим из первого товара дня как fallback
|
||||
const targetNmId = nmId || dailyData.find(d => d.date === date)?.products[0]?.nmId?.toString() || '0'
|
||||
|
||||
await createExternalAd({
|
||||
variables: {
|
||||
@ -944,12 +1022,12 @@ export function AdvertisingTab({ selectedPeriod, useCustomDates, startDate, endD
|
||||
url: ad.url,
|
||||
cost: ad.cost,
|
||||
date: date,
|
||||
nmId: nmId
|
||||
nmId: targetNmId
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
console.log('External ad created successfully')
|
||||
console.log('External ad created successfully for nmId:', targetNmId)
|
||||
} catch (error) {
|
||||
console.error('Error creating external ad:', error)
|
||||
}
|
||||
@ -1237,6 +1315,34 @@ export function AdvertisingTab({ selectedPeriod, useCustomDates, startDate, endD
|
||||
</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">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={spendingChartData}>
|
||||
@ -1273,20 +1379,24 @@ export function AdvertisingTab({ selectedPeriod, useCustomDates, startDate, endD
|
||||
return null
|
||||
}}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="wbSum"
|
||||
fill="#3b82f6"
|
||||
name="ВБ реклама"
|
||||
radius={[2, 2, 0, 0]}
|
||||
opacity={0.8}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="externalSum"
|
||||
fill="#ec4899"
|
||||
name="Внешняя реклама"
|
||||
radius={[2, 2, 0, 0]}
|
||||
opacity={0.8}
|
||||
/>
|
||||
{showWbAds && (
|
||||
<Bar
|
||||
dataKey="wbSum"
|
||||
fill="#3b82f6"
|
||||
name="ВБ реклама"
|
||||
radius={[2, 2, 0, 0]}
|
||||
opacity={0.8}
|
||||
/>
|
||||
)}
|
||||
{showExternalAds && (
|
||||
<Bar
|
||||
dataKey="externalSum"
|
||||
fill="#ec4899"
|
||||
name="Внешняя реклама"
|
||||
radius={[2, 2, 0, 0]}
|
||||
opacity={0.8}
|
||||
/>
|
||||
)}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
@ -1,23 +1,49 @@
|
||||
"use client"
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Button } from '@/components/ui/button'
|
||||
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 {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Plus,
|
||||
Trash2,
|
||||
Link,
|
||||
Package,
|
||||
Copy,
|
||||
Eye,
|
||||
MousePointer,
|
||||
ShoppingCart,
|
||||
DollarSign
|
||||
DollarSign,
|
||||
Search,
|
||||
Package
|
||||
} 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 {
|
||||
id: string
|
||||
name: string
|
||||
@ -70,7 +96,7 @@ interface SimpleAdvertisingTableProps {
|
||||
dailyData: DailyAdvertisingData[]
|
||||
productPhotos?: Map<number, string>
|
||||
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
|
||||
onUpdateExternalAd?: (date: string, adId: string, updates: Partial<ExternalAd>) => void
|
||||
onGenerateLink?: (date: string, adId: string, adName: string, adUrl: string) => void
|
||||
@ -85,16 +111,32 @@ export function SimpleAdvertisingTable({
|
||||
onUpdateExternalAd,
|
||||
onGenerateLink
|
||||
}: SimpleAdvertisingTableProps) {
|
||||
const [showWbAds, setShowWbAds] = useState(true)
|
||||
const [showExternalAds, setShowExternalAds] = useState(true)
|
||||
const [expandedDays, setExpandedDays] = useState<Set<string>>(new Set())
|
||||
const [expandedProducts, setExpandedProducts] = useState<Set<string>>(new Set())
|
||||
const { user } = useAuth()
|
||||
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({
|
||||
name: '',
|
||||
url: '',
|
||||
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) => {
|
||||
if (value === 0) return '—'
|
||||
@ -113,66 +155,102 @@ export function SimpleAdvertisingTable({
|
||||
return value > 0 ? new Intl.NumberFormat('ru-RU').format(value) : '—'
|
||||
}
|
||||
|
||||
const toggleDay = (date: string) => {
|
||||
const newExpanded = new Set(expandedDays)
|
||||
if (newExpanded.has(date)) {
|
||||
newExpanded.delete(date)
|
||||
} else {
|
||||
newExpanded.add(date)
|
||||
}
|
||||
setExpandedDays(newExpanded)
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
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) {
|
||||
console.log('Creating external ad for:', { productKey, date, nmId, selectedProduct })
|
||||
|
||||
onAddExternalAd(date, {
|
||||
name: newExternalAd.name,
|
||||
url: newExternalAd.url,
|
||||
cost: parseFloat(newExternalAd.cost) || 0
|
||||
})
|
||||
}, nmId)
|
||||
setNewExternalAd({ name: '', url: '', cost: '' })
|
||||
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 (
|
||||
<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="grid grid-cols-5 gap-4 p-3 text-white text-sm font-medium">
|
||||
<div>Дата</div>
|
||||
@ -183,7 +261,18 @@ export function SimpleAdvertisingTable({
|
||||
</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">
|
||||
{dailyData.map((day) => {
|
||||
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)
|
||||
const dayTotalCost = dayWbCost + dayExternalCost
|
||||
const dayOrders = day.totalOrders
|
||||
const isExpanded = expandedDays.has(day.date)
|
||||
|
||||
return (
|
||||
<div key={day.date}>
|
||||
{/* Основная строка дня - как в Figma */}
|
||||
<div
|
||||
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="grid grid-cols-5 gap-4 p-3 border-b border-white/5 bg-white/5 text-white/80 text-sm font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
<span className="font-medium">{day.date}</span>
|
||||
<span className="font-bold">{formatDate(day.date)}</span>
|
||||
</div>
|
||||
<div className="text-center">{formatCurrency(dayTotalCost)}</div>
|
||||
<div className="text-center">{dayOrders}</div>
|
||||
<div className="text-center">
|
||||
{showWbAds ? formatCurrency(dayWbCost) : '—'}
|
||||
</div>
|
||||
<div className="text-center">
|
||||
{showExternalAds ? formatCurrency(dayExternalCost) : '—'}
|
||||
</div>
|
||||
<div className="text-center">{formatCurrency(dayWbCost)}</div>
|
||||
<div className="text-center">{formatCurrency(dayExternalCost)}</div>
|
||||
</div>
|
||||
|
||||
{/* Раскрывающееся содержимое с товарами */}
|
||||
{isExpanded && (
|
||||
<div className="bg-white/2 border-b border-white/5">
|
||||
{day.products.map((product) => {
|
||||
{/* Товары всегда видны - второй уровень */}
|
||||
<div className="border-b border-white/5">
|
||||
{day.products.map((product) => {
|
||||
const productKey = `${day.date}-${product.nmId}`
|
||||
const isProductExpanded = expandedProducts.has(productKey)
|
||||
const productExternalCost = product.advertising.externalAds.reduce((sum, ad) => sum + ad.cost, 0)
|
||||
const productTotalCost = product.totalCost + productExternalCost
|
||||
|
||||
return (
|
||||
<div key={productKey} className="ml-6">
|
||||
{/* Строка товара */}
|
||||
<div
|
||||
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-center gap-2">
|
||||
{isProductExpanded ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
||||
<div className="flex items-center gap-2">
|
||||
{productPhotos.has(product.nmId) && (
|
||||
<img
|
||||
src={productPhotos.get(product.nmId)}
|
||||
alt={product.name}
|
||||
className="w-6 h-6 rounded object-cover"
|
||||
/>
|
||||
)}
|
||||
<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 key={productKey} className="ml-4">
|
||||
{/* Строка товара с многострочными ячейками */}
|
||||
<div className="grid grid-cols-5 gap-4 p-3 border-b border-white/5 text-white/70 text-sm">
|
||||
{/* Карточка товара */}
|
||||
<div className="flex items-start gap-2">
|
||||
{productPhotos.has(product.nmId) && (
|
||||
<img
|
||||
src={productPhotos.get(product.nmId)}
|
||||
alt={product.name}
|
||||
className="w-8 h-8 rounded object-cover flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<div className="text-white/90 text-xs font-medium">{product.name}</div>
|
||||
<div className="text-white/60 text-xs">#{product.nmId}</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="ml-6 bg-white/1 border-b border-white/5">
|
||||
{/* ВБ кампании */}
|
||||
{showWbAds && product.advertising.wbCampaigns.map((campaign) => (
|
||||
<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" />
|
||||
<span>ВБ #{campaign.campaignId}</span>
|
||||
</div>
|
||||
<div className="text-center">{formatCurrency(campaign.cost)}</div>
|
||||
<div className="text-center">{campaign.orders}</div>
|
||||
<div className="text-center">{formatCurrency(campaign.cost)}</div>
|
||||
<div className="text-center">—</div>
|
||||
{/* Общая сумма */}
|
||||
<div className="text-center self-start">{formatCurrency(productTotalCost)}</div>
|
||||
|
||||
{/* Заказы */}
|
||||
<div className="text-center self-start">{product.totalOrders}</div>
|
||||
|
||||
{/* Реклама ВБ - многострочная ячейка */}
|
||||
<div className="text-center">
|
||||
{product.advertising.wbCampaigns.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{product.advertising.wbCampaigns.map((campaign, index) => (
|
||||
<div key={campaign.campaignId} className="text-xs bg-blue-500/10 rounded p-1">
|
||||
<div className="text-blue-400 font-medium">
|
||||
{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>
|
||||
))}
|
||||
) : (
|
||||
<span className="text-white/40">—</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Внешняя реклама */}
|
||||
{showExternalAds && product.advertising.externalAds.map((ad) => (
|
||||
<div key={ad.id} className="grid grid-cols-5 gap-4 p-2 text-white/60 text-xs">
|
||||
<div className="flex items-center gap-1">
|
||||
<MousePointer className="h-3 w-3 text-purple-400" />
|
||||
<span className="truncate max-w-24">{ad.name}</span>
|
||||
</div>
|
||||
<div className="text-center">{formatCurrency(ad.cost)}</div>
|
||||
<div className="text-center">—</div>
|
||||
<div className="text-center">—</div>
|
||||
<div className="text-center flex items-center justify-center gap-1">
|
||||
{formatCurrency(ad.cost)}
|
||||
<div className="flex gap-1">
|
||||
{/* Реклама внешняя - многострочная ячейка с кнопками */}
|
||||
<div className="text-center">
|
||||
<div className="space-y-1">
|
||||
{product.advertising.externalAds.map((ad) => (
|
||||
<div key={ad.id} className="text-xs bg-purple-500/10 rounded p-1">
|
||||
<div className="text-purple-400 font-medium truncate">{ad.name}</div>
|
||||
<div className="text-white/80">{formatCurrency(ad.cost)}</div>
|
||||
<div className="text-white/60">{ad.clicks || 0} кликов</div>
|
||||
<div className="flex gap-1 justify-center mt-1">
|
||||
{onGenerateLink && (
|
||||
<Button
|
||||
size="sm"
|
||||
@ -293,9 +362,10 @@ export function SimpleAdvertisingTable({
|
||||
e.stopPropagation()
|
||||
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>
|
||||
)}
|
||||
{onRemoveExternalAd && (
|
||||
@ -306,22 +376,21 @@ export function SimpleAdvertisingTable({
|
||||
e.stopPropagation()
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
|
||||
{/* Кнопка добавления внешней рекламы */}
|
||||
{onAddExternalAd && (
|
||||
<div className="grid grid-cols-5 gap-4 p-2">
|
||||
<div className="col-span-5">
|
||||
{/* Инлайн форма добавления внешней рекламы */}
|
||||
{onAddExternalAd && (
|
||||
<div>
|
||||
{showAddForm === productKey ? (
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="bg-white/5 rounded p-2 space-y-2">
|
||||
<Input
|
||||
placeholder="Название"
|
||||
value={newExternalAd.name}
|
||||
@ -339,44 +408,201 @@ export function SimpleAdvertisingTable({
|
||||
type="number"
|
||||
value={newExternalAd.cost}
|
||||
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
|
||||
size="sm"
|
||||
onClick={() => handleAddExternalAdLocal(day.date, product.nmId)}
|
||||
className="h-6 bg-green-600 hover:bg-green-700 text-white text-xs px-2"
|
||||
>
|
||||
Добавить
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setShowAddForm(null)}
|
||||
variant="ghost"
|
||||
className="h-6 text-white/60 hover:bg-white/10 text-xs px-2"
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
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
|
||||
size="sm"
|
||||
onClick={() => setShowAddForm(null)}
|
||||
variant="ghost"
|
||||
className="h-6 text-white/60 hover:bg-white/10 text-xs px-2"
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
<button
|
||||
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" />
|
||||
Добавить внешнюю рекламу
|
||||
</Button>
|
||||
<Plus className="h-3 w-3" />
|
||||
Добавить
|
||||
</button>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
})}
|
||||
|
Reference in New Issue
Block a user