diff --git a/VERSION b/VERSION index 64b47fc..fe67504 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -6.21.3 +6.22.0 diff --git a/play-life-backend/main.go b/play-life-backend/main.go index e1b7f0e..a0b8228 100644 --- a/play-life-backend/main.go +++ b/play-life-backend/main.go @@ -764,6 +764,106 @@ func calculateNextShowAtFromRepetitionPeriod(repetitionPeriod string, fromDate t return &nextDate } +// getShoppingMedianDailyConsumption возвращает медиану daily_consumption из volume_records для товара +func (a *App) getShoppingMedianDailyConsumption(itemID int, volumeBase float64) float64 { + var median sql.NullFloat64 + a.DB.QueryRow(` + SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY daily_consumption) + FROM shopping_volume_records + WHERE item_id = $1 AND daily_consumption IS NOT NULL AND daily_consumption > 0 + `, itemID).Scan(&median) + if median.Valid && median.Float64 > 0 { + return median.Float64 + } + // дефолт: volume_base хватает на 7 дней + if volumeBase > 0 { + return volumeBase / 7.0 + } + return 1.0 / 7.0 +} + +// getShoppingMedianPurchased возвращает медиану докупок для товара +func (a *App) getShoppingMedianPurchased(itemID int) *float64 { + var median sql.NullFloat64 + a.DB.QueryRow(` + SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY volume_purchased) + FROM shopping_volume_records + WHERE item_id = $1 AND volume_purchased IS NOT NULL AND volume_purchased > 0 + `, itemID).Scan(&median) + if median.Valid { + return &median.Float64 + } + return nil +} + +// getShoppingLastVolumeRecord возвращает последнюю запись volume_records для товара +type ShoppingVolumeRecord struct { + ID int + VolumeRemaining float64 + VolumePurchased float64 + DailyConsumption sql.NullFloat64 + CreatedAt time.Time +} + +func (a *App) getShoppingLastVolumeRecord(itemID int) *ShoppingVolumeRecord { + var rec ShoppingVolumeRecord + err := a.DB.QueryRow(` + SELECT id, COALESCE(volume_remaining, 0), COALESCE(volume_purchased, 0), daily_consumption, created_at + FROM shopping_volume_records + WHERE item_id = $1 + ORDER BY created_at DESC + LIMIT 1 + `, itemID).Scan(&rec.ID, &rec.VolumeRemaining, &rec.VolumePurchased, &rec.DailyConsumption, &rec.CreatedAt) + if err != nil { + return nil + } + return &rec +} + +// calculateShoppingEstimatedRemaining вычисляет расчётный текущий остаток +func calculateShoppingEstimatedRemaining(lastTotal float64, dailyConsumption float64, lastDate time.Time) float64 { + daysPassed := time.Since(lastDate).Hours() / 24.0 + estimated := lastTotal - daysPassed*dailyConsumption + if estimated < 0 { + return 0 + } + return estimated +} + +// calculateShoppingPeriodDailyConsumption вычисляет daily_consumption за период между двумя записями +func calculateShoppingPeriodDailyConsumption(prevRemaining, prevPurchased float64, prevDate time.Time, currentRemaining float64) *float64 { + prevTotal := prevRemaining + prevPurchased + consumed := prevTotal - currentRemaining + if consumed < 0 { + return nil + } + days := time.Since(prevDate).Hours() / 24.0 + if days <= 0 { + return nil + } + daily := consumed / days + return &daily +} + +// enrichShoppingItemWithVolumeData добавляет расчётные поля к ShoppingItem +func (a *App) enrichShoppingItemWithVolumeData(item *ShoppingItem) { + lastRec := a.getShoppingLastVolumeRecord(item.ID) + medianDaily := a.getShoppingMedianDailyConsumption(item.ID, item.VolumeBase) + + item.DailyConsumption = &medianDaily + + if lastRec != nil { + lastTotal := lastRec.VolumeRemaining + lastRec.VolumePurchased + estimated := calculateShoppingEstimatedRemaining(lastTotal, medianDaily, lastRec.CreatedAt) + item.EstimatedRemaining = &estimated + } else { + zero := 0.0 + item.EstimatedRemaining = &zero + } + + item.MedianPurchased = a.getShoppingMedianPurchased(item.ID) +} + // ============================================ // Auth types // ============================================ @@ -4847,6 +4947,7 @@ func main() { protected.HandleFunc("/api/shopping/items/{id}/postpone", app.postponeShoppingItemHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/api/shopping/items/{id}/history", app.getShoppingItemHistoryHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/shopping/history/{id}", app.deleteShoppingItemHistoryHandler).Methods("DELETE", "OPTIONS") + protected.HandleFunc("/api/shopping/items/{id}/volume-records", app.getShoppingVolumeRecordsHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/shopping/groups", app.getShoppingGroupSuggestionsHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/shopping/invite/{token}", app.getShoppingBoardInviteInfoHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/shopping/invite/{token}/join", app.joinShoppingBoardHandler).Methods("POST", "OPTIONS") @@ -18926,20 +19027,23 @@ type ShoppingBoard struct { } type ShoppingItem struct { - ID int `json:"id"` - UserID int `json:"user_id"` - BoardID int `json:"board_id"` - AuthorID int `json:"author_id"` - Name string `json:"name"` - Description *string `json:"description,omitempty"` - GroupName *string `json:"group_name,omitempty"` - VolumeBase float64 `json:"volume_base"` - RepetitionPeriod *string `json:"repetition_period,omitempty"` - NextShowAt *string `json:"next_show_at,omitempty"` - Completed int `json:"completed"` - LastCompletedAt *string `json:"last_completed_at,omitempty"` - CreatedAt string `json:"created_at"` - LastVolume *float64 `json:"last_volume,omitempty"` + ID int `json:"id"` + UserID int `json:"user_id"` + BoardID int `json:"board_id"` + AuthorID int `json:"author_id"` + Name string `json:"name"` + Description *string `json:"description,omitempty"` + GroupName *string `json:"group_name,omitempty"` + VolumeBase float64 `json:"volume_base"` + RepetitionPeriod *string `json:"repetition_period,omitempty"` + NextShowAt *string `json:"next_show_at,omitempty"` + Completed int `json:"completed"` + LastCompletedAt *string `json:"last_completed_at,omitempty"` + CreatedAt string `json:"created_at"` + LastVolume *float64 `json:"last_volume,omitempty"` + DailyConsumption *float64 `json:"daily_consumption,omitempty"` + EstimatedRemaining *float64 `json:"estimated_remaining,omitempty"` + MedianPurchased *float64 `json:"median_purchased,omitempty"` } type ShoppingItemRequest struct { @@ -18951,7 +19055,14 @@ type ShoppingItemRequest struct { } type CompleteShoppingItemRequest struct { - Volume *float64 `json:"volume,omitempty"` + Volume *float64 `json:"volume,omitempty"` + VolumeRemaining *float64 `json:"volume_remaining,omitempty"` + VolumePurchased *float64 `json:"volume_purchased,omitempty"` +} + +type PostponeShoppingItemRequest struct { + NextShowAt *string `json:"next_show_at"` + VolumeRemaining *float64 `json:"volume_remaining,omitempty"` } type ShoppingJoinBoardResponse struct { @@ -19741,6 +19852,9 @@ func (a *App) getShoppingItemsHandler(w http.ResponseWriter, r *http.Request) { } item.CreatedAt = createdAt.Format(time.RFC3339) + // Добавляем расчётные поля по объёму + a.enrichShoppingItemWithVolumeData(&item) + items = append(items, item) } @@ -19816,6 +19930,15 @@ func (a *App) createShoppingItemHandler(w http.ResponseWriter, r *http.Request) return } + // Создаём начальную запись в volume_records + _, vrErr := a.DB.Exec(` + INSERT INTO shopping_volume_records (item_id, user_id, action_type, volume_remaining, volume_purchased, created_at) + VALUES ($1, $2, 'create', 0, 0, NOW()) + `, itemID, userID) + if vrErr != nil { + log.Printf("Error creating initial volume record: %v", vrErr) + } + item := ShoppingItem{ ID: itemID, UserID: boardOwnerID, @@ -19932,6 +20055,9 @@ func (a *App) getShoppingItemHandler(w http.ResponseWriter, r *http.Request) { item.LastVolume = &lastVolume.Float64 } + // Добавляем расчётные поля по объёму + a.enrichShoppingItemWithVolumeData(&item) + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(item) } @@ -20210,55 +20336,83 @@ func (a *App) completeShoppingItemHandler(w http.ResponseWriter, r *http.Request } } - actualVolume := volumeBase - if req.Volume != nil && *req.Volume > 0 { - actualVolume = *req.Volume + // Определяем volume_remaining и volume_purchased из нового или старого формата + var volumeRemaining float64 + var volumePurchased float64 + if req.VolumeRemaining != nil { + volumeRemaining = *req.VolumeRemaining + } + if req.VolumePurchased != nil { + volumePurchased = *req.VolumePurchased + } else if req.Volume != nil && *req.Volume > 0 { + // Обратная совместимость: старый формат {volume} → считаем как докупку + volumePurchased = *req.Volume + } else { + volumePurchased = volumeBase } now := time.Now() + newTotal := volumeRemaining + volumePurchased - if repetitionPeriod.Valid && repetitionPeriod.String != "" { - // Рассчитываем next_show_at с учётом объёма - multiplier := actualVolume / volumeBase - baseNext := calculateNextShowAtFromRepetitionPeriod(repetitionPeriod.String, now) - if baseNext != nil { - // Применяем множитель: сдвигаем пропорционально объёму - baseDuration := baseNext.Sub(time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())) - adjustedDuration := time.Duration(float64(baseDuration) * multiplier) - nextShowAt := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).Add(adjustedDuration) - - _, err = a.DB.Exec(` - UPDATE shopping_items - SET completed = completed + 1, last_completed_at = $1, next_show_at = $2, updated_at = NOW() - WHERE id = $3 - `, now, nextShowAt, itemID) - } else { - _, err = a.DB.Exec(` - UPDATE shopping_items - SET completed = completed + 1, last_completed_at = $1, updated_at = NOW() - WHERE id = $2 - `, now, itemID) + // Рассчитываем daily_consumption за текущий период + lastRec := a.getShoppingLastVolumeRecord(itemID) + var periodDaily *float64 + if lastRec != nil { + prevTotal := lastRec.VolumeRemaining + lastRec.VolumePurchased + consumed := prevTotal - volumeRemaining + days := now.Sub(lastRec.CreatedAt).Hours() / 24.0 + if days > 0 && consumed >= 0 { + d := consumed / days + periodDaily = &d } - } else { - // Одноразовый товар - помечаем удалённым - _, err = a.DB.Exec(` - UPDATE shopping_items - SET completed = completed + 1, last_completed_at = $1, deleted = TRUE, updated_at = NOW() - WHERE id = $2 - `, now, itemID) } + // Записываем в volume_records + _, vrErr := a.DB.Exec(` + INSERT INTO shopping_volume_records (item_id, user_id, action_type, volume_remaining, volume_purchased, daily_consumption, created_at) + VALUES ($1, $2, 'purchase', $3, $4, $5, $6) + `, itemID, userID, volumeRemaining, volumePurchased, periodDaily, now) + if vrErr != nil { + log.Printf("Error inserting volume record: %v", vrErr) + } + + // Получаем медиану daily_consumption (включая новую запись) + medianDaily := a.getShoppingMedianDailyConsumption(itemID, volumeBase) + + // Рассчитываем next_show_at (показываем на 3 дня раньше чем закончится) + var daysUntilEmpty float64 + if medianDaily > 0 { + daysUntilEmpty = newTotal / medianDaily + } else { + daysUntilEmpty = 365 // cap + } + if daysUntilEmpty > 365 { + daysUntilEmpty = 365 + } + daysUntilShow := daysUntilEmpty - 3 + if daysUntilShow < 0 { + daysUntilShow = 0 + } + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + nextShowAt := today.Add(time.Duration(daysUntilShow*24) * time.Hour) + + _, err = a.DB.Exec(` + UPDATE shopping_items + SET completed = completed + 1, last_completed_at = $1, next_show_at = $2, updated_at = NOW() + WHERE id = $3 + `, now, nextShowAt, itemID) + if err != nil { log.Printf("Error completing shopping item: %v", err) sendErrorWithCORS(w, "Error completing item", http.StatusInternalServerError) return } - // Записываем в историю покупок + // Записываем в историю покупок (обратная совместимость) _, histErr := a.DB.Exec(` INSERT INTO shopping_item_history (item_id, user_id, name, volume, completed_at) VALUES ($1, $2, $3, $4, $5) - `, itemID, userID, itemName, actualVolume, now) + `, itemID, userID, itemName, volumePurchased, now) if histErr != nil { log.Printf("Error inserting shopping item history: %v", histErr) } @@ -20292,15 +20446,16 @@ func (a *App) postponeShoppingItemHandler(w http.ResponseWriter, r *http.Request return } - var req PostponeTaskRequest + var req PostponeShoppingItemRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) return } - // Проверяем что товар существует и получаем board_id + // Проверяем что товар существует и получаем board_id и volume_base var boardID int - err = a.DB.QueryRow(`SELECT board_id FROM shopping_items WHERE id = $1 AND deleted = FALSE`, itemID).Scan(&boardID) + var volumeBase float64 + err = a.DB.QueryRow(`SELECT board_id, volume_base FROM shopping_items WHERE id = $1 AND deleted = FALSE`, itemID).Scan(&boardID, &volumeBase) if err == sql.ErrNoRows { sendErrorWithCORS(w, "Item not found", http.StatusNotFound) return @@ -20319,6 +20474,7 @@ func (a *App) postponeShoppingItemHandler(w http.ResponseWriter, r *http.Request } } + // Парсим next_show_at var nextShowAtValue interface{} if req.NextShowAt == nil || *req.NextShowAt == "" { nextShowAtValue = nil @@ -20331,6 +20487,30 @@ func (a *App) postponeShoppingItemHandler(w http.ResponseWriter, r *http.Request nextShowAtValue = nextShowAt } + // Если передан volume_remaining — записываем в volume_records + if req.VolumeRemaining != nil { + now := time.Now() + lastRec := a.getShoppingLastVolumeRecord(itemID) + var periodDaily *float64 + if lastRec != nil { + prevTotal := lastRec.VolumeRemaining + lastRec.VolumePurchased + consumed := prevTotal - *req.VolumeRemaining + days := now.Sub(lastRec.CreatedAt).Hours() / 24.0 + if days > 0 && consumed >= 0 { + d := consumed / days + periodDaily = &d + } + } + + _, vrErr := a.DB.Exec(` + INSERT INTO shopping_volume_records (item_id, user_id, action_type, volume_remaining, volume_purchased, daily_consumption, next_show_at, created_at) + VALUES ($1, $2, 'postpone', $3, 0, $4, $5, $6) + `, itemID, userID, *req.VolumeRemaining, periodDaily, nextShowAtValue, now) + if vrErr != nil { + log.Printf("Error inserting postpone volume record: %v", vrErr) + } + } + _, err = a.DB.Exec(` UPDATE shopping_items SET next_show_at = $1, updated_at = NOW() @@ -20552,6 +20732,102 @@ func (a *App) deleteShoppingItemHistoryHandler(w http.ResponseWriter, r *http.Re }) } +// getShoppingVolumeRecordsHandler возвращает записи об остатках товара +func (a *App) getShoppingVolumeRecordsHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + setCORSHeaders(w) + w.WriteHeader(http.StatusOK) + return + } + setCORSHeaders(w) + + userID, ok := getUserIDFromContext(r) + if !ok { + sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) + return + } + + vars := mux.Vars(r) + itemID, err := strconv.Atoi(vars["id"]) + if err != nil { + sendErrorWithCORS(w, "Invalid item ID", http.StatusBadRequest) + return + } + + // Получаем board_id товара для проверки доступа + var boardID int + err = a.DB.QueryRow(`SELECT board_id FROM shopping_items WHERE id = $1`, itemID).Scan(&boardID) + if err == sql.ErrNoRows { + sendErrorWithCORS(w, "Item not found", http.StatusNotFound) + return + } + if err != nil { + sendErrorWithCORS(w, "Error getting item", http.StatusInternalServerError) + return + } + + // Проверяем доступ + var ownerID int + a.DB.QueryRow(`SELECT owner_id FROM shopping_boards WHERE id = $1`, boardID).Scan(&ownerID) + if ownerID != userID { + var isMember bool + a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM shopping_board_members WHERE board_id = $1 AND user_id = $2)`, + boardID, userID).Scan(&isMember) + if !isMember { + sendErrorWithCORS(w, "Access denied", http.StatusForbidden) + return + } + } + + rows, err := a.DB.Query(` + SELECT id, action_type, COALESCE(volume_remaining, 0), COALESCE(volume_purchased, 0), daily_consumption, next_show_at, created_at + FROM shopping_volume_records + WHERE item_id = $1 + ORDER BY created_at DESC + LIMIT 50 + `, itemID) + if err != nil { + log.Printf("Error getting volume records: %v", err) + sendErrorWithCORS(w, "Error getting volume records", http.StatusInternalServerError) + return + } + defer rows.Close() + + type VolumeRecordEntry struct { + ID int `json:"id"` + ActionType string `json:"action_type"` + VolumeRemaining float64 `json:"volume_remaining"` + VolumePurchased float64 `json:"volume_purchased"` + DailyConsumption *float64 `json:"daily_consumption,omitempty"` + NextShowAt *string `json:"next_show_at,omitempty"` + CreatedAt string `json:"created_at"` + } + + records := []VolumeRecordEntry{} + for rows.Next() { + var entry VolumeRecordEntry + var dailyConsumption sql.NullFloat64 + var nextShowAt sql.NullTime + var createdAt time.Time + if err := rows.Scan(&entry.ID, &entry.ActionType, &entry.VolumeRemaining, &entry.VolumePurchased, &dailyConsumption, &nextShowAt, &createdAt); err != nil { + log.Printf("Error scanning volume record: %v", err) + continue + } + if dailyConsumption.Valid { + entry.DailyConsumption = &dailyConsumption.Float64 + } + if nextShowAt.Valid { + s := nextShowAt.Time.Format(time.RFC3339) + entry.NextShowAt = &s + } + entry.CreatedAt = createdAt.Format(time.RFC3339) + records = append(records, entry) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(records) +} + // getPurchaseBoardsInfoHandler возвращает доски пользователя с их группами для формы закупок func (a *App) getPurchaseBoardsInfoHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { @@ -20798,6 +21074,9 @@ func (a *App) getPurchaseItemsHandler(w http.ResponseWriter, r *http.Request) { } item.CreatedAt = createdAt.Format(time.RFC3339) + // Добавляем расчётные поля по объёму + a.enrichShoppingItemWithVolumeData(&item) + items = append(items, item) } rows.Close() diff --git a/play-life-backend/migrations/000031_shopping_volume_tracking.down.sql b/play-life-backend/migrations/000031_shopping_volume_tracking.down.sql new file mode 100644 index 0000000..00bfed4 --- /dev/null +++ b/play-life-backend/migrations/000031_shopping_volume_tracking.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS shopping_volume_records; diff --git a/play-life-backend/migrations/000031_shopping_volume_tracking.up.sql b/play-life-backend/migrations/000031_shopping_volume_tracking.up.sql new file mode 100644 index 0000000..b1b384e --- /dev/null +++ b/play-life-backend/migrations/000031_shopping_volume_tracking.up.sql @@ -0,0 +1,19 @@ +-- Отдельная таблица записей об остатках (создаётся при каждом выполнении и переносе) +CREATE TABLE shopping_volume_records ( + id SERIAL PRIMARY KEY, + item_id INTEGER NOT NULL REFERENCES shopping_items(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id), + action_type VARCHAR(20) NOT NULL, + volume_remaining NUMERIC(10,4), + volume_purchased NUMERIC(10,4), + daily_consumption NUMERIC(10,4), + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_shopping_volume_records_item_id ON shopping_volume_records(item_id); + +-- Создаём начальные записи для всех существующих товаров (остаток 0, дата = created_at) +INSERT INTO shopping_volume_records (item_id, user_id, action_type, volume_remaining, volume_purchased, created_at) +SELECT id, user_id, 'create', 0, 0, created_at +FROM shopping_items +WHERE deleted = FALSE; diff --git a/play-life-backend/migrations/000032_volume_records_next_show_at.down.sql b/play-life-backend/migrations/000032_volume_records_next_show_at.down.sql new file mode 100644 index 0000000..9e1edce --- /dev/null +++ b/play-life-backend/migrations/000032_volume_records_next_show_at.down.sql @@ -0,0 +1 @@ +ALTER TABLE shopping_volume_records DROP COLUMN IF EXISTS next_show_at; diff --git a/play-life-backend/migrations/000032_volume_records_next_show_at.up.sql b/play-life-backend/migrations/000032_volume_records_next_show_at.up.sql new file mode 100644 index 0000000..3b448db --- /dev/null +++ b/play-life-backend/migrations/000032_volume_records_next_show_at.up.sql @@ -0,0 +1 @@ +ALTER TABLE shopping_volume_records ADD COLUMN next_show_at TIMESTAMP; diff --git a/play-life-web/package.json b/play-life-web/package.json index 91e2493..5b517f7 100644 --- a/play-life-web/package.json +++ b/play-life-web/package.json @@ -1,6 +1,6 @@ { "name": "play-life-web", - "version": "6.21.3", + "version": "6.22.0", "type": "module", "scripts": { "dev": "vite", diff --git a/play-life-web/src/components/PurchaseScreen.jsx b/play-life-web/src/components/PurchaseScreen.jsx index 6570192..0be3db4 100644 --- a/play-life-web/src/components/PurchaseScreen.jsx +++ b/play-life-web/src/components/PurchaseScreen.jsx @@ -7,6 +7,7 @@ import { DayPicker } from 'react-day-picker' import { ru } from 'react-day-picker/locale' import 'react-day-picker/style.css' import './TaskList.css' +import './TaskDetail.css' import './ShoppingList.css' // Форматирование даты в YYYY-MM-DD (локальное время) @@ -59,6 +60,7 @@ function PurchaseScreen({ onNavigate, purchaseConfigId, taskId, taskName }) { const [selectedItemForDetail, setSelectedItemForDetail] = useState(null) const [selectedItemForPostpone, setSelectedItemForPostpone] = useState(null) const [postponeDate, setPostponeDate] = useState('') + const [postponeRemaining, setPostponeRemaining] = useState('') const [isPostponing, setIsPostponing] = useState(false) const [toast, setToast] = useState(null) const [expandedFuture, setExpandedFuture] = useState({}) @@ -153,6 +155,7 @@ function PurchaseScreen({ onNavigate, purchaseConfigId, taskId, taskName }) { if (currentPostpone) { setSelectedItemForPostpone(null) setPostponeDate('') + setPostponeRemaining('') historyPushedForPostponeRef.current = false return } @@ -246,6 +249,7 @@ function PurchaseScreen({ onNavigate, purchaseConfigId, taskId, taskName }) { } else { setSelectedItemForPostpone(null) setPostponeDate('') + setPostponeRemaining('') } } @@ -254,10 +258,14 @@ function PurchaseScreen({ onNavigate, purchaseConfigId, taskId, taskName }) { setIsPostponing(true) try { const nextShowAt = new Date(dateStr + 'T00:00:00') + const payload = { next_show_at: nextShowAt.toISOString() } + if (postponeRemaining.trim()) { + payload.volume_remaining = parseFloat(postponeRemaining) + } const res = await authFetch(`/api/shopping/items/${selectedItemForPostpone.id}/postpone`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ next_show_at: nextShowAt.toISOString() }) + body: JSON.stringify(payload) }) if (res.ok) { setToast({ message: 'Дата обновлена', type: 'success' }) @@ -299,10 +307,14 @@ function PurchaseScreen({ onNavigate, purchaseConfigId, taskId, taskName }) { if (!selectedItemForPostpone) return setIsPostponing(true) try { + const payload = { next_show_at: null } + if (postponeRemaining.trim()) { + payload.volume_remaining = parseFloat(postponeRemaining) + } const res = await authFetch(`/api/shopping/items/${selectedItemForPostpone.id}/postpone`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ next_show_at: null }) + body: JSON.stringify(payload) }) if (res.ok) { setToast({ message: 'Дата убрана', type: 'success' }) @@ -316,6 +328,28 @@ function PurchaseScreen({ onNavigate, purchaseConfigId, taskId, taskName }) { } } + // Автообновление даты в календаре при вводе остатка + useEffect(() => { + if (!selectedItemForPostpone) return + if (selectedItemForPostpone.daily_consumption > 0) { + const remaining = postponeRemaining.trim() ? parseFloat(postponeRemaining) : (selectedItemForPostpone.estimated_remaining ?? 0) + if (!isNaN(remaining) && remaining >= 0) { + const daily = selectedItemForPostpone.daily_consumption + const daysLeft = remaining / daily + const target = new Date() + target.setHours(0, 0, 0, 0) + target.setDate(target.getDate() + Math.ceil(daysLeft)) + setPostponeDate(formatDateToLocal(target)) + return + } + } + // Фолбэк: завтра + const tomorrow = new Date() + tomorrow.setHours(0, 0, 0, 0) + tomorrow.setDate(tomorrow.getDate() + 1) + setPostponeDate(formatDateToLocal(tomorrow)) + }, [postponeRemaining, selectedItemForPostpone]) + const renderItem = (item) => { let dateDisplay = null if (item.next_show_at) { @@ -350,7 +384,14 @@ function PurchaseScreen({ onNavigate, purchaseConfigId, taskId, taskName }) {