6.22.0: Авторасчёт сроков товаров по истории
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m23s

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
poignatov
2026-03-19 11:37:39 +03:00
parent 664adcfaa5
commit f1c12fd81a
13 changed files with 716 additions and 323 deletions

View File

@@ -1 +1 @@
6.21.3 6.22.0

View File

@@ -764,6 +764,106 @@ func calculateNextShowAtFromRepetitionPeriod(repetitionPeriod string, fromDate t
return &nextDate 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 // 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}/postpone", app.postponeShoppingItemHandler).Methods("POST", "OPTIONS")
protected.HandleFunc("/api/shopping/items/{id}/history", app.getShoppingItemHistoryHandler).Methods("GET", "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/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/groups", app.getShoppingGroupSuggestionsHandler).Methods("GET", "OPTIONS")
protected.HandleFunc("/api/shopping/invite/{token}", app.getShoppingBoardInviteInfoHandler).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") protected.HandleFunc("/api/shopping/invite/{token}/join", app.joinShoppingBoardHandler).Methods("POST", "OPTIONS")
@@ -18940,6 +19041,9 @@ type ShoppingItem struct {
LastCompletedAt *string `json:"last_completed_at,omitempty"` LastCompletedAt *string `json:"last_completed_at,omitempty"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
LastVolume *float64 `json:"last_volume,omitempty"` 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 { type ShoppingItemRequest struct {
@@ -18952,6 +19056,13 @@ type ShoppingItemRequest struct {
type CompleteShoppingItemRequest 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 { type ShoppingJoinBoardResponse struct {
@@ -19741,6 +19852,9 @@ func (a *App) getShoppingItemsHandler(w http.ResponseWriter, r *http.Request) {
} }
item.CreatedAt = createdAt.Format(time.RFC3339) item.CreatedAt = createdAt.Format(time.RFC3339)
// Добавляем расчётные поля по объёму
a.enrichShoppingItemWithVolumeData(&item)
items = append(items, item) items = append(items, item)
} }
@@ -19816,6 +19930,15 @@ func (a *App) createShoppingItemHandler(w http.ResponseWriter, r *http.Request)
return 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{ item := ShoppingItem{
ID: itemID, ID: itemID,
UserID: boardOwnerID, UserID: boardOwnerID,
@@ -19932,6 +20055,9 @@ func (a *App) getShoppingItemHandler(w http.ResponseWriter, r *http.Request) {
item.LastVolume = &lastVolume.Float64 item.LastVolume = &lastVolume.Float64
} }
// Добавляем расчётные поля по объёму
a.enrichShoppingItemWithVolumeData(&item)
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(item) json.NewEncoder(w).Encode(item)
} }
@@ -20210,43 +20336,71 @@ func (a *App) completeShoppingItemHandler(w http.ResponseWriter, r *http.Request
} }
} }
actualVolume := volumeBase // Определяем volume_remaining и volume_purchased из нового или старого формата
if req.Volume != nil && *req.Volume > 0 { var volumeRemaining float64
actualVolume = *req.Volume 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() now := time.Now()
newTotal := volumeRemaining + volumePurchased
if repetitionPeriod.Valid && repetitionPeriod.String != "" { // Рассчитываем daily_consumption за текущий период
// Рассчитываем next_show_at с учётом объёма lastRec := a.getShoppingLastVolumeRecord(itemID)
multiplier := actualVolume / volumeBase var periodDaily *float64
baseNext := calculateNextShowAtFromRepetitionPeriod(repetitionPeriod.String, now) if lastRec != nil {
if baseNext != nil { prevTotal := lastRec.VolumeRemaining + lastRec.VolumePurchased
// Применяем множитель: сдвигаем пропорционально объёму consumed := prevTotal - volumeRemaining
baseDuration := baseNext.Sub(time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())) days := now.Sub(lastRec.CreatedAt).Hours() / 24.0
adjustedDuration := time.Duration(float64(baseDuration) * multiplier) if days > 0 && consumed >= 0 {
nextShowAt := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).Add(adjustedDuration) d := consumed / days
periodDaily = &d
}
}
// Записываем в 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(` _, err = a.DB.Exec(`
UPDATE shopping_items UPDATE shopping_items
SET completed = completed + 1, last_completed_at = $1, next_show_at = $2, updated_at = NOW() SET completed = completed + 1, last_completed_at = $1, next_show_at = $2, updated_at = NOW()
WHERE id = $3 WHERE id = $3
`, now, nextShowAt, itemID) `, 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)
}
} 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)
}
if err != nil { if err != nil {
log.Printf("Error completing shopping item: %v", err) log.Printf("Error completing shopping item: %v", err)
@@ -20254,11 +20408,11 @@ func (a *App) completeShoppingItemHandler(w http.ResponseWriter, r *http.Request
return return
} }
// Записываем в историю покупок // Записываем в историю покупок (обратная совместимость)
_, histErr := a.DB.Exec(` _, histErr := a.DB.Exec(`
INSERT INTO shopping_item_history (item_id, user_id, name, volume, completed_at) INSERT INTO shopping_item_history (item_id, user_id, name, volume, completed_at)
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, $5)
`, itemID, userID, itemName, actualVolume, now) `, itemID, userID, itemName, volumePurchased, now)
if histErr != nil { if histErr != nil {
log.Printf("Error inserting shopping item history: %v", histErr) log.Printf("Error inserting shopping item history: %v", histErr)
} }
@@ -20292,15 +20446,16 @@ func (a *App) postponeShoppingItemHandler(w http.ResponseWriter, r *http.Request
return return
} }
var req PostponeTaskRequest var req PostponeShoppingItemRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest)
return return
} }
// Проверяем что товар существует и получаем board_id // Проверяем что товар существует и получаем board_id и volume_base
var boardID int 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 { if err == sql.ErrNoRows {
sendErrorWithCORS(w, "Item not found", http.StatusNotFound) sendErrorWithCORS(w, "Item not found", http.StatusNotFound)
return return
@@ -20319,6 +20474,7 @@ func (a *App) postponeShoppingItemHandler(w http.ResponseWriter, r *http.Request
} }
} }
// Парсим next_show_at
var nextShowAtValue interface{} var nextShowAtValue interface{}
if req.NextShowAt == nil || *req.NextShowAt == "" { if req.NextShowAt == nil || *req.NextShowAt == "" {
nextShowAtValue = nil nextShowAtValue = nil
@@ -20331,6 +20487,30 @@ func (a *App) postponeShoppingItemHandler(w http.ResponseWriter, r *http.Request
nextShowAtValue = nextShowAt 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(` _, err = a.DB.Exec(`
UPDATE shopping_items UPDATE shopping_items
SET next_show_at = $1, updated_at = NOW() 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 возвращает доски пользователя с их группами для формы закупок // getPurchaseBoardsInfoHandler возвращает доски пользователя с их группами для формы закупок
func (a *App) getPurchaseBoardsInfoHandler(w http.ResponseWriter, r *http.Request) { func (a *App) getPurchaseBoardsInfoHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" { if r.Method == "OPTIONS" {
@@ -20798,6 +21074,9 @@ func (a *App) getPurchaseItemsHandler(w http.ResponseWriter, r *http.Request) {
} }
item.CreatedAt = createdAt.Format(time.RFC3339) item.CreatedAt = createdAt.Format(time.RFC3339)
// Добавляем расчётные поля по объёму
a.enrichShoppingItemWithVolumeData(&item)
items = append(items, item) items = append(items, item)
} }
rows.Close() rows.Close()

View File

@@ -0,0 +1 @@
DROP TABLE IF EXISTS shopping_volume_records;

View File

@@ -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;

View File

@@ -0,0 +1 @@
ALTER TABLE shopping_volume_records DROP COLUMN IF EXISTS next_show_at;

View File

@@ -0,0 +1 @@
ALTER TABLE shopping_volume_records ADD COLUMN next_show_at TIMESTAMP;

View File

@@ -1,6 +1,6 @@
{ {
"name": "play-life-web", "name": "play-life-web",
"version": "6.21.3", "version": "6.22.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -7,6 +7,7 @@ import { DayPicker } from 'react-day-picker'
import { ru } from 'react-day-picker/locale' import { ru } from 'react-day-picker/locale'
import 'react-day-picker/style.css' import 'react-day-picker/style.css'
import './TaskList.css' import './TaskList.css'
import './TaskDetail.css'
import './ShoppingList.css' import './ShoppingList.css'
// Форматирование даты в YYYY-MM-DD (локальное время) // Форматирование даты в YYYY-MM-DD (локальное время)
@@ -59,6 +60,7 @@ function PurchaseScreen({ onNavigate, purchaseConfigId, taskId, taskName }) {
const [selectedItemForDetail, setSelectedItemForDetail] = useState(null) const [selectedItemForDetail, setSelectedItemForDetail] = useState(null)
const [selectedItemForPostpone, setSelectedItemForPostpone] = useState(null) const [selectedItemForPostpone, setSelectedItemForPostpone] = useState(null)
const [postponeDate, setPostponeDate] = useState('') const [postponeDate, setPostponeDate] = useState('')
const [postponeRemaining, setPostponeRemaining] = useState('')
const [isPostponing, setIsPostponing] = useState(false) const [isPostponing, setIsPostponing] = useState(false)
const [toast, setToast] = useState(null) const [toast, setToast] = useState(null)
const [expandedFuture, setExpandedFuture] = useState({}) const [expandedFuture, setExpandedFuture] = useState({})
@@ -153,6 +155,7 @@ function PurchaseScreen({ onNavigate, purchaseConfigId, taskId, taskName }) {
if (currentPostpone) { if (currentPostpone) {
setSelectedItemForPostpone(null) setSelectedItemForPostpone(null)
setPostponeDate('') setPostponeDate('')
setPostponeRemaining('')
historyPushedForPostponeRef.current = false historyPushedForPostponeRef.current = false
return return
} }
@@ -246,6 +249,7 @@ function PurchaseScreen({ onNavigate, purchaseConfigId, taskId, taskName }) {
} else { } else {
setSelectedItemForPostpone(null) setSelectedItemForPostpone(null)
setPostponeDate('') setPostponeDate('')
setPostponeRemaining('')
} }
} }
@@ -254,10 +258,14 @@ function PurchaseScreen({ onNavigate, purchaseConfigId, taskId, taskName }) {
setIsPostponing(true) setIsPostponing(true)
try { try {
const nextShowAt = new Date(dateStr + 'T00:00:00') 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`, { const res = await authFetch(`/api/shopping/items/${selectedItemForPostpone.id}/postpone`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ next_show_at: nextShowAt.toISOString() }) body: JSON.stringify(payload)
}) })
if (res.ok) { if (res.ok) {
setToast({ message: 'Дата обновлена', type: 'success' }) setToast({ message: 'Дата обновлена', type: 'success' })
@@ -299,10 +307,14 @@ function PurchaseScreen({ onNavigate, purchaseConfigId, taskId, taskName }) {
if (!selectedItemForPostpone) return if (!selectedItemForPostpone) return
setIsPostponing(true) setIsPostponing(true)
try { 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`, { const res = await authFetch(`/api/shopping/items/${selectedItemForPostpone.id}/postpone`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ next_show_at: null }) body: JSON.stringify(payload)
}) })
if (res.ok) { if (res.ok) {
setToast({ message: 'Дата убрана', type: 'success' }) 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) => { const renderItem = (item) => {
let dateDisplay = null let dateDisplay = null
if (item.next_show_at) { if (item.next_show_at) {
@@ -350,7 +384,14 @@ function PurchaseScreen({ onNavigate, purchaseConfigId, taskId, taskName }) {
</div> </div>
<div className="task-name-container"> <div className="task-name-container">
<div className="task-name-wrapper"> <div className="task-name-wrapper">
<div className="task-name">{item.name}</div> <div className="task-name">
{item.name}
{item.estimated_remaining > 0 && (
<span style={{ color: '#9ca3af', fontSize: '0.8em', marginLeft: '6px' }}>
~{Math.round(item.estimated_remaining * 10) / 10}
</span>
)}
</div>
{dateDisplay && ( {dateDisplay && (
<div className="task-next-show-date">{dateDisplay}</div> <div className="task-next-show-date">{dateDisplay}</div>
)} )}
@@ -362,28 +403,8 @@ function PurchaseScreen({ onNavigate, purchaseConfigId, taskId, taskName }) {
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
setSelectedItemForPostpone(item) setSelectedItemForPostpone(item)
// Предвыбираем дату "по плану" если она не совпадает с текущей next_show_at setPostponeRemaining(item.estimated_remaining != null && item.estimated_remaining > 0 ? (Math.round(item.estimated_remaining * 10) / 10).toString() : '')
const now2 = new Date() // Дата рассчитывается в useEffect по остатку
now2.setHours(0, 0, 0, 0)
let planned
if (item.repetition_period) {
planned = calculateNextDateFromRepetitionPeriod(item.repetition_period)
}
if (!planned) {
planned = new Date(now2)
planned.setDate(planned.getDate() + 1)
}
planned.setHours(0, 0, 0, 0)
const plannedStr = formatDateToLocal(planned)
let nextShowStr = null
if (item.next_show_at) {
nextShowStr = formatDateToLocal(new Date(item.next_show_at))
}
if (plannedStr !== nextShowStr) {
setPostponeDate(plannedStr)
} else {
setPostponeDate('')
}
}} }}
title="Перенести" title="Перенести"
> >
@@ -565,6 +586,41 @@ function PurchaseScreen({ onNavigate, purchaseConfigId, taskId, taskName }) {
</button> </button>
</div> </div>
<div className="task-postpone-modal-content"> <div className="task-postpone-modal-content">
<div style={{ marginBottom: '0.5rem' }}>
<label className="progression-label" style={{ marginBottom: '0.25rem' }}>Остаток</label>
<div className="progression-input-wrapper">
<input
type="number"
step="any"
value={postponeRemaining}
onChange={(e) => setPostponeRemaining(e.target.value)}
placeholder={selectedItemForPostpone.estimated_remaining != null ? (Math.round(selectedItemForPostpone.estimated_remaining * 10) / 10).toString() : '0'}
className="progression-input"
style={{ paddingRight: postponeRemaining ? '2rem' : '0.75rem' }}
/>
{postponeRemaining && (
<button
type="button"
onClick={() => setPostponeRemaining('')}
style={{
position: 'absolute',
right: '8px',
top: '50%',
transform: 'translateY(-50%)',
background: 'none',
border: 'none',
color: '#9ca3af',
cursor: 'pointer',
fontSize: '1.1rem',
padding: '4px',
lineHeight: 1,
}}
>
</button>
)}
</div>
</div>
<div className="task-postpone-calendar"> <div className="task-postpone-calendar">
<DayPicker <DayPicker
mode="single" mode="single"
@@ -598,13 +654,21 @@ function PurchaseScreen({ onNavigate, purchaseConfigId, taskId, taskName }) {
Завтра Завтра
</button> </button>
)} )}
{!isCurrentDatePlanned && ( {selectedItemForPostpone.daily_consumption > 0 && (
<button <button
className="task-postpone-quick-button" className="task-postpone-quick-button"
onClick={() => handlePostponeSubmitWithDate(plannedDateStr)} onClick={() => {
const remaining = postponeRemaining.trim() ? parseFloat(postponeRemaining) : (selectedItemForPostpone.estimated_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))
handlePostponeSubmitWithDate(formatDateToLocal(target))
}}
disabled={isPostponing} disabled={isPostponing}
> >
По плану По остатку
</button> </button>
)} )}
{nextShowAtStr && ( {nextShowAtStr && (

View File

@@ -10,7 +10,8 @@ function ShoppingItemDetail({ itemId, onClose, onRefresh, onItemCompleted, onNav
const [item, setItem] = useState(null) const [item, setItem] = useState(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState(null) const [error, setError] = useState(null)
const [volumeValue, setVolumeValue] = useState('') const [volumeRemaining, setVolumeRemaining] = useState('')
const [volumePurchased, setVolumePurchased] = useState('')
const [isCompleting, setIsCompleting] = useState(false) const [isCompleting, setIsCompleting] = useState(false)
const [toastMessage, setToastMessage] = useState(null) const [toastMessage, setToastMessage] = useState(null)
@@ -38,7 +39,8 @@ function ShoppingItemDetail({ itemId, onClose, onRefresh, onItemCompleted, onNav
setItem(null) setItem(null)
setLoading(true) setLoading(true)
setError(null) setError(null)
setVolumeValue('') setVolumeRemaining('')
setVolumePurchased('')
} }
}, [itemId, fetchItem]) }, [itemId, fetchItem])
@@ -48,13 +50,21 @@ function ShoppingItemDetail({ itemId, onClose, onRefresh, onItemCompleted, onNav
setIsCompleting(true) setIsCompleting(true)
try { try {
const payload = {} const payload = {}
if (volumeValue.trim()) { if (volumeRemaining.trim()) {
payload.volume = parseFloat(volumeValue) payload.volume_remaining = parseFloat(volumeRemaining)
if (isNaN(payload.volume)) { if (isNaN(payload.volume_remaining)) {
throw new Error('Неверное значение объёма') throw new Error('Неверное значение остатка')
} }
} else { } else {
payload.volume = item.last_volume ?? item.volume_base payload.volume_remaining = item.estimated_remaining ?? 0
}
if (volumePurchased.trim()) {
payload.volume_purchased = parseFloat(volumePurchased)
if (isNaN(payload.volume_purchased)) {
throw new Error('Неверное значение докупки')
}
} else {
payload.volume_purchased = item.median_purchased ?? item.volume_base ?? 1
} }
const response = await authFetch(`/api/shopping/items/${itemId}/complete`, { const response = await authFetch(`/api/shopping/items/${itemId}/complete`, {
@@ -169,15 +179,50 @@ function ShoppingItemDetail({ itemId, onClose, onRefresh, onItemCompleted, onNav
</button> </button>
</div> </div>
<div className="shopping-item-complete-row"> <div style={{ display: 'flex', gap: '0.5rem', alignItems: 'flex-end', marginBottom: '0.75rem' }}>
<label className="progression-label">Объём</label> <div style={{ flex: 1 }}>
<label className="progression-label">Остаток</label>
<div className="progression-input-wrapper"> <div className="progression-input-wrapper">
<input <input
type="number" type="number"
step="any" step="any"
value={volumeValue} value={volumeRemaining}
onChange={(e) => setVolumeValue(e.target.value)} onChange={(e) => setVolumeRemaining(e.target.value)}
placeholder={(item.last_volume ?? item.volume_base)?.toString() || '1'} placeholder={item.estimated_remaining != null ? Math.round(item.estimated_remaining * 10) / 10 + '' : '0'}
className="progression-input"
/>
{volumeRemaining && (
<button
type="button"
onClick={() => setVolumeRemaining('')}
style={{
position: 'absolute',
right: '8px',
top: '50%',
transform: 'translateY(-50%)',
background: 'none',
border: 'none',
color: '#9ca3af',
cursor: 'pointer',
fontSize: '1.1rem',
padding: '4px',
lineHeight: 1,
}}
>
</button>
)}
</div>
</div>
<div style={{ flex: 1 }}>
<label className="progression-label">Докуплено</label>
<div className="progression-input-wrapper">
<input
type="number"
step="any"
value={volumePurchased}
onChange={(e) => setVolumePurchased(e.target.value)}
placeholder={(item.median_purchased ?? item.volume_base ?? 1).toString()}
className="progression-input" className="progression-input"
/> />
<div className="progression-controls-capsule"> <div className="progression-controls-capsule">
@@ -185,10 +230,10 @@ function ShoppingItemDetail({ itemId, onClose, onRefresh, onItemCompleted, onNav
type="button" type="button"
className="progression-control-btn progression-control-minus" className="progression-control-btn progression-control-minus"
onClick={() => { onClick={() => {
const base = item.last_volume ?? item.volume_base ?? 1 const base = item.median_purchased ?? item.volume_base ?? 1
const current = volumeValue.trim() ? parseFloat(volumeValue) : base const current = volumePurchased.trim() ? parseFloat(volumePurchased) : base
const step = item.volume_base || 1 const step = item.volume_base || 1
setVolumeValue((current - step).toString()) setVolumePurchased(Math.max(0, current - step).toString())
}} }}
> >
@@ -197,10 +242,10 @@ function ShoppingItemDetail({ itemId, onClose, onRefresh, onItemCompleted, onNav
type="button" type="button"
className="progression-control-btn progression-control-plus" className="progression-control-btn progression-control-plus"
onClick={() => { onClick={() => {
const base = item.last_volume ?? item.volume_base ?? 1 const base = item.median_purchased ?? item.volume_base ?? 1
const current = volumeValue.trim() ? parseFloat(volumeValue) : base const current = volumePurchased.trim() ? parseFloat(volumePurchased) : base
const step = item.volume_base || 1 const step = item.volume_base || 1
setVolumeValue((current + step).toString()) setVolumePurchased((current + step).toString())
}} }}
> >
+ +
@@ -208,6 +253,7 @@ function ShoppingItemDetail({ itemId, onClose, onRefresh, onItemCompleted, onNav
</div> </div>
</div> </div>
</div> </div>
</div>
<div className="task-action-left"> <div className="task-action-left">
<button <button
onClick={handleComplete} onClick={handleComplete}

View File

@@ -13,8 +13,6 @@ function ShoppingItemForm({ onNavigate, itemId, boardId, previousTab, onSaved, i
const [groupName, setGroupName] = useState('') const [groupName, setGroupName] = useState('')
const [groupSuggestions, setGroupSuggestions] = useState([]) const [groupSuggestions, setGroupSuggestions] = useState([])
const [volumeBase, setVolumeBase] = useState('') const [volumeBase, setVolumeBase] = useState('')
const [repetitionPeriodValue, setRepetitionPeriodValue] = useState('')
const [repetitionPeriodType, setRepetitionPeriodType] = useState('day')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [loadingItem, setLoadingItem] = useState(false) const [loadingItem, setLoadingItem] = useState(false)
const [isDeleting, setIsDeleting] = useState(false) const [isDeleting, setIsDeleting] = useState(false)
@@ -59,26 +57,6 @@ function ShoppingItemForm({ onNavigate, itemId, boardId, previousTab, onSaved, i
if (data.volume_base && data.volume_base !== 1) { if (data.volume_base && data.volume_base !== 1) {
setVolumeBase(data.volume_base.toString()) setVolumeBase(data.volume_base.toString())
} }
if (data.repetition_period) {
const parts = data.repetition_period.trim().split(/\s+/)
if (parts.length >= 2) {
const value = parseInt(parts[0], 10)
const unit = parts[1].toLowerCase()
setRepetitionPeriodValue(value.toString())
// Map PostgreSQL units to our types
if (unit.startsWith('day')) setRepetitionPeriodType('day')
else if (unit.startsWith('week') || unit === 'wks' || unit === 'wk') setRepetitionPeriodType('week')
else if (unit.startsWith('mon')) setRepetitionPeriodType('month')
else if (unit.startsWith('year') || unit === 'yrs' || unit === 'yr') setRepetitionPeriodType('year')
else if (unit.startsWith('hour') || unit === 'hrs' || unit === 'hr') setRepetitionPeriodType('hour')
else if (unit.startsWith('min')) setRepetitionPeriodType('minute')
// Handle PostgreSQL weeks-as-days: "7 days" -> 1 week
if (unit.startsWith('day') && value % 7 === 0 && value >= 7) {
setRepetitionPeriodValue((value / 7).toString())
setRepetitionPeriodType('week')
}
}
}
} else { } else {
setToastMessage({ text: 'Ошибка загрузки товара', type: 'error' }) setToastMessage({ text: 'Ошибка загрузки товара', type: 'error' })
} }
@@ -95,28 +73,14 @@ function ShoppingItemForm({ onNavigate, itemId, boardId, previousTab, onSaved, i
return return
} }
if (!hasValidPeriod) {
setToastMessage({ text: 'Укажите период повторения', type: 'error' })
return
}
setLoading(true) setLoading(true)
try { try {
let repetitionPeriod = null
if (repetitionPeriodValue && repetitionPeriodValue.trim() !== '') {
const val = parseInt(repetitionPeriodValue.trim(), 10)
if (!isNaN(val) && val > 0) {
repetitionPeriod = `${val} ${repetitionPeriodType}`
}
}
const vb = volumeBase.trim() ? parseFloat(volumeBase.trim()) : null const vb = volumeBase.trim() ? parseFloat(volumeBase.trim()) : null
const payload = { const payload = {
name: name.trim(), name: name.trim(),
description: description.trim() || null, description: description.trim() || null,
group_name: groupName.trim() || null, group_name: groupName.trim() || null,
volume_base: vb && vb > 0 ? vb : null, volume_base: vb && vb > 0 ? vb : null,
repetition_period: repetitionPeriod,
} }
let url, method let url, method
@@ -224,8 +188,6 @@ function ShoppingItemForm({ onNavigate, itemId, boardId, previousTab, onSaved, i
window.history.back() window.history.back()
} }
const hasValidPeriod = repetitionPeriodValue && repetitionPeriodValue.trim() !== '' && parseInt(repetitionPeriodValue.trim(), 10) > 0
if (loadingItem) { if (loadingItem) {
return ( return (
<div className="shopping-item-form"> <div className="shopping-item-form">
@@ -292,7 +254,7 @@ function ShoppingItemForm({ onNavigate, itemId, boardId, previousTab, onSaved, i
</div> </div>
<div className="form-group"> <div className="form-group">
<label htmlFor="item-volume">Объём</label> <label htmlFor="item-volume">Шаги объёма</label>
<input <input
id="item-volume" id="item-volume"
type="number" type="number"
@@ -305,36 +267,6 @@ function ShoppingItemForm({ onNavigate, itemId, boardId, previousTab, onSaved, i
/> />
</div> </div>
<div className="form-group">
<label htmlFor="item-repetition">Повторения</label>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<span className="repetition-label">Хватает на</span>
<input
id="item-repetition"
type="number"
min="0"
className="form-input"
value={repetitionPeriodValue}
onChange={e => setRepetitionPeriodValue(e.target.value)}
placeholder="Число"
style={{ flex: '1' }}
/>
<select
value={repetitionPeriodType}
onChange={e => setRepetitionPeriodType(e.target.value)}
className="form-input"
style={{ width: '120px' }}
>
<option value="minute">Минута</option>
<option value="hour">Час</option>
<option value="day">День</option>
<option value="week">Неделя</option>
<option value="month">Месяц</option>
<option value="year">Год</option>
</select>
</div>
</div>
</div> </div>
{toastMessage && ( {toastMessage && (
@@ -361,7 +293,7 @@ function ShoppingItemForm({ onNavigate, itemId, boardId, previousTab, onSaved, i
}}> }}>
<button <button
onClick={handleSave} onClick={handleSave}
disabled={loading || isDeleting || isCopying || !name.trim() || !hasValidPeriod} disabled={loading || isDeleting || isCopying || !name.trim()}
style={{ style={{
flex: 1, flex: 1,
maxWidth: '42rem', maxWidth: '42rem',

View File

@@ -1,28 +1,25 @@
import React, { useState, useEffect, useCallback } from 'react' import React, { useState, useEffect, useCallback } from 'react'
import { useAuth } from './auth/AuthContext' import { useAuth } from './auth/AuthContext'
import LoadingError from './LoadingError' import LoadingError from './LoadingError'
import Toast from './Toast'
import './Integrations.css' import './Integrations.css'
function ShoppingItemHistory({ itemId, onNavigate }) { function ShoppingItemHistory({ itemId, onNavigate }) {
const { authFetch } = useAuth() const { authFetch } = useAuth()
const [history, setHistory] = useState([]) const [records, setRecords] = useState([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState(null) const [error, setError] = useState(null)
const [toastMessage, setToastMessage] = useState(null)
const [deletingId, setDeletingId] = useState(null)
const fetchHistory = useCallback(async () => { const fetchRecords = useCallback(async () => {
if (!itemId) return if (!itemId) return
try { try {
setLoading(true) setLoading(true)
setError(null) setError(null)
const response = await authFetch(`/api/shopping/items/${itemId}/history`) const response = await authFetch(`/api/shopping/items/${itemId}/volume-records`)
if (!response.ok) { if (!response.ok) {
throw new Error('Ошибка загрузки истории') throw new Error('Ошибка загрузки истории')
} }
const data = await response.json() const data = await response.json()
setHistory(Array.isArray(data) ? data : []) setRecords(Array.isArray(data) ? data : [])
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
} finally { } finally {
@@ -31,26 +28,8 @@ function ShoppingItemHistory({ itemId, onNavigate }) {
}, [itemId, authFetch]) }, [itemId, authFetch])
useEffect(() => { useEffect(() => {
fetchHistory() fetchRecords()
}, [fetchHistory]) }, [fetchRecords])
const handleDelete = async (historyId) => {
setDeletingId(historyId)
try {
const response = await authFetch(`/api/shopping/history/${historyId}`, {
method: 'DELETE',
})
if (!response.ok) {
throw new Error('Ошибка удаления')
}
setHistory(prev => prev.filter(entry => entry.id !== historyId))
setToastMessage({ text: 'Запись удалена', type: 'success' })
} catch (err) {
setToastMessage({ text: err.message || 'Ошибка', type: 'error' })
} finally {
setDeletingId(null)
}
}
const formatDate = (dateStr) => { const formatDate = (dateStr) => {
const date = new Date(dateStr) const date = new Date(dateStr)
@@ -64,11 +43,29 @@ function ShoppingItemHistory({ itemId, onNavigate }) {
} }
const formatVolume = (volume) => { const formatVolume = (volume) => {
if (volume === 1) return '1' if (volume == null) return ''
const rounded = Math.round(volume * 10000) / 10000 const rounded = Math.round(volume * 10) / 10
return rounded.toString() return rounded.toString()
} }
const getActionLabel = (actionType) => {
switch (actionType) {
case 'purchase': return 'Покупка'
case 'postpone': return 'Перенос'
case 'create': return 'Создание'
default: return actionType
}
}
const getActionColor = (actionType) => {
switch (actionType) {
case 'purchase': return '#059669'
case 'postpone': return '#d97706'
case 'create': return '#6b7280'
default: return '#6b7280'
}
}
return ( return (
<div className="max-w-2xl mx-auto"> <div className="max-w-2xl mx-auto">
{onNavigate && ( {onNavigate && (
@@ -89,89 +86,69 @@ function ShoppingItemHistory({ itemId, onNavigate }) {
</div> </div>
</div> </div>
) : error ? ( ) : error ? (
<LoadingError onRetry={fetchHistory} /> <LoadingError onRetry={fetchRecords} />
) : history.length === 0 ? ( ) : records.length === 0 ? (
<> <>
<h2 className="text-2xl font-semibold text-gray-800 mb-6" style={{ marginTop: '1.25rem' }}>История покупок</h2> <h2 className="text-2xl font-semibold text-gray-800 mb-6" style={{ marginTop: '1.25rem' }}>История</h2>
<div className="flex justify-center items-center py-16"> <div className="flex justify-center items-center py-16">
<div className="text-gray-500 text-lg">История пуста</div> <div className="text-gray-500 text-lg">История пуста</div>
</div> </div>
</> </>
) : ( ) : (
<div> <div>
<h2 className="text-2xl font-semibold text-gray-800 mb-6" style={{ marginTop: '1.25rem' }}>История покупок</h2> <h2 className="text-2xl font-semibold text-gray-800 mb-6" style={{ marginTop: '1.25rem' }}>История</h2>
<div className="space-y-3"> <div className="space-y-3">
{history.map((entry) => ( {records.map((record) => {
<div const total = (record.volume_remaining || 0) + (record.volume_purchased || 0)
key={entry.id}
className="bg-white rounded-lg p-4 shadow-sm border border-gray-200 relative"
>
<button
onClick={() => handleDelete(entry.id)}
disabled={deletingId === entry.id}
className="absolute top-4 right-4"
style={{
color: '#6b7280',
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '0.25rem',
borderRadius: '0.25rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '24px',
height: '24px',
transition: 'all 0.2s',
opacity: deletingId === entry.id ? 0.5 : 1,
zIndex: 10
}}
onMouseEnter={(e) => {
if (deletingId !== entry.id) {
e.currentTarget.style.backgroundColor = '#f3f4f6'
e.currentTarget.style.color = '#1f2937'
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent'
e.currentTarget.style.color = '#6b7280'
}}
title="Удалить"
>
{deletingId === entry.id ? (
<svg className="w-5 h-5 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
) : (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 6h18"></path>
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path>
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path>
</svg>
)}
</button>
<div className="text-gray-800 pr-8">
{entry.name}
</div>
<div className="text-gray-800 font-semibold mt-1">
Объём: {formatVolume(entry.volume)}
</div>
<div className="text-xs text-gray-500 mt-2">
{formatDate(entry.completed_at)}
</div>
</div>
))}
</div>
</div>
)}
{toastMessage && ( return (
<Toast <div
message={toastMessage.text} key={record.id}
type={toastMessage.type} className="bg-white rounded-lg p-4 shadow-sm border border-gray-200"
onClose={() => setToastMessage(null)} >
/> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ color: getActionColor(record.action_type), fontWeight: 600, fontSize: '0.9rem' }}>
{getActionLabel(record.action_type)}
</span>
<span className="text-xs text-gray-500">
{formatDate(record.created_at)}
</span>
</div>
{record.action_type === 'purchase' && (
<div className="text-gray-800 mt-2">
{formatVolume(record.volume_remaining)} {formatVolume(total)}
</div>
)}
{record.action_type === 'postpone' && (
<div className="text-gray-800 mt-2">
{record.next_show_at ? (() => {
const d = new Date(record.next_show_at)
const day = d.getDate()
const months = ['янв', 'фев', 'мар', 'апр', 'май', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек']
return `на ${day} ${months[d.getMonth()]}`
})() : 'Без даты'}
{record.volume_remaining != null && (
<span className="text-gray-500" style={{ marginLeft: '8px' }}>
(остаток: {formatVolume(record.volume_remaining)})
</span>
)}
</div>
)}
{record.action_type === 'create' && (
<div className="text-gray-500 text-sm mt-1">
Остаток: {formatVolume(record.volume_remaining)}
</div>
)}
{record.daily_consumption != null && record.daily_consumption > 0 && (
<div className="text-gray-500 text-xs mt-1">
~{formatVolume(record.daily_consumption)}/день
</div>
)}
</div>
)
})}
</div>
</div>
)} )}
</div> </div>
) )

View File

@@ -9,6 +9,7 @@ import { DayPicker } from 'react-day-picker'
import { ru } from 'react-day-picker/locale' import { ru } from 'react-day-picker/locale'
import 'react-day-picker/style.css' import 'react-day-picker/style.css'
import './TaskList.css' import './TaskList.css'
import './TaskDetail.css'
import './ShoppingList.css' import './ShoppingList.css'
const BOARDS_CACHE_KEY = 'shopping_boards_cache' const BOARDS_CACHE_KEY = 'shopping_boards_cache'
@@ -129,6 +130,7 @@ function ShoppingList({ onNavigate, refreshTrigger = 0, isActive = false, initia
const [selectedItemForDetail, setSelectedItemForDetail] = useState(null) const [selectedItemForDetail, setSelectedItemForDetail] = useState(null)
const [selectedItemForPostpone, setSelectedItemForPostpone] = useState(null) const [selectedItemForPostpone, setSelectedItemForPostpone] = useState(null)
const [postponeDate, setPostponeDate] = useState('') const [postponeDate, setPostponeDate] = useState('')
const [postponeRemaining, setPostponeRemaining] = useState('')
const [isPostponing, setIsPostponing] = useState(false) const [isPostponing, setIsPostponing] = useState(false)
const [toast, setToast] = useState(null) const [toast, setToast] = useState(null)
const initialFetchDoneRef = useRef(false) const initialFetchDoneRef = useRef(false)
@@ -319,6 +321,7 @@ function ShoppingList({ onNavigate, refreshTrigger = 0, isActive = false, initia
if (currentPostpone) { if (currentPostpone) {
setSelectedItemForPostpone(null) setSelectedItemForPostpone(null)
setPostponeDate('') setPostponeDate('')
setPostponeRemaining('')
historyPushedForPostponeRef.current = false historyPushedForPostponeRef.current = false
if (currentDetail) { if (currentDetail) {
window.history.pushState({ modalOpen: true, type: 'shopping-detail' }, '', window.location.href) window.history.pushState({ modalOpen: true, type: 'shopping-detail' }, '', window.location.href)
@@ -420,28 +423,9 @@ function ShoppingList({ onNavigate, refreshTrigger = 0, isActive = false, initia
const openPostpone = (item) => { const openPostpone = (item) => {
setSelectedItemForPostpone(item) setSelectedItemForPostpone(item)
// Предвыбираем дату "по плану" если она не совпадает с текущей next_show_at const remainingVal = item.estimated_remaining != null && item.estimated_remaining > 0 ? (Math.round(item.estimated_remaining * 10) / 10).toString() : ''
const now2 = new Date() setPostponeRemaining(remainingVal)
now2.setHours(0, 0, 0, 0) // Дата рассчитывается в useEffect по остатку
let planned
if (item.repetition_period) {
planned = calculateNextDateFromRepetitionPeriod(item.repetition_period)
}
if (!planned) {
planned = new Date(now2)
planned.setDate(planned.getDate() + 1)
}
planned.setHours(0, 0, 0, 0)
const plannedStr = formatDateToLocal(planned)
let nextShowStr = null
if (item.next_show_at) {
nextShowStr = formatDateToLocal(new Date(item.next_show_at))
}
if (plannedStr !== nextShowStr) {
setPostponeDate(plannedStr)
} else {
setPostponeDate('')
}
} }
// Модалка переноса // Модалка переноса
@@ -452,6 +436,7 @@ function ShoppingList({ onNavigate, refreshTrigger = 0, isActive = false, initia
historyPushedForPostponeRef.current = false historyPushedForPostponeRef.current = false
setSelectedItemForPostpone(null) setSelectedItemForPostpone(null)
setPostponeDate('') setPostponeDate('')
setPostponeRemaining('')
} }
} }
@@ -474,10 +459,14 @@ function ShoppingList({ onNavigate, refreshTrigger = 0, isActive = false, initia
setIsPostponing(true) setIsPostponing(true)
try { try {
const nextShowAt = new Date(dateStr + 'T00:00:00') 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`, { const res = await authFetch(`/api/shopping/items/${selectedItemForPostpone.id}/postpone`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ next_show_at: nextShowAt.toISOString() }) body: JSON.stringify(payload)
}) })
if (res.ok) { if (res.ok) {
setToast({ message: 'Дата перенесена', type: 'success' }) setToast({ message: 'Дата перенесена', type: 'success' })
@@ -507,10 +496,14 @@ function ShoppingList({ onNavigate, refreshTrigger = 0, isActive = false, initia
if (!selectedItemForPostpone) return if (!selectedItemForPostpone) return
setIsPostponing(true) setIsPostponing(true)
try { 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`, { const res = await authFetch(`/api/shopping/items/${selectedItemForPostpone.id}/postpone`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ next_show_at: null }) body: JSON.stringify(payload)
}) })
if (res.ok) { if (res.ok) {
setToast({ message: 'Дата убрана', type: 'success' }) setToast({ message: 'Дата убрана', type: 'success' })
@@ -525,6 +518,28 @@ function ShoppingList({ onNavigate, refreshTrigger = 0, isActive = false, initia
} }
// Автообновление даты в календаре при вводе остатка
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 groupNames = useMemo(() => { const groupNames = useMemo(() => {
const names = Object.keys(groupedItems) const names = Object.keys(groupedItems)
return names.sort((a, b) => { return names.sort((a, b) => {
@@ -658,7 +673,14 @@ function ShoppingList({ onNavigate, refreshTrigger = 0, isActive = false, initia
</div> </div>
<div className="task-name-container"> <div className="task-name-container">
<div className="task-name-wrapper"> <div className="task-name-wrapper">
<div className="task-name">{item.name}</div> <div className="task-name">
{item.name}
{item.estimated_remaining > 0 && (
<span style={{ color: '#9ca3af', fontSize: '0.8em', marginLeft: '6px' }}>
~{Math.round(item.estimated_remaining * 10) / 10}
</span>
)}
</div>
{dateDisplay && ( {dateDisplay && (
<div className="task-next-show-date">{dateDisplay}</div> <div className="task-next-show-date">{dateDisplay}</div>
)} )}
@@ -722,7 +744,14 @@ function ShoppingList({ onNavigate, refreshTrigger = 0, isActive = false, initia
</div> </div>
<div className="task-name-container"> <div className="task-name-container">
<div className="task-name-wrapper"> <div className="task-name-wrapper">
<div className="task-name">{item.name}</div> <div className="task-name">
{item.name}
{item.estimated_remaining > 0 && (
<span style={{ color: '#9ca3af', fontSize: '0.8em', marginLeft: '6px' }}>
~{Math.round(item.estimated_remaining * 10) / 10}
</span>
)}
</div>
{dateDisplay && ( {dateDisplay && (
<div className="task-next-show-date">{dateDisplay}</div> <div className="task-next-show-date">{dateDisplay}</div>
)} )}
@@ -811,6 +840,41 @@ function ShoppingList({ onNavigate, refreshTrigger = 0, isActive = false, initia
</button> </button>
</div> </div>
<div className="task-postpone-modal-content"> <div className="task-postpone-modal-content">
<div style={{ marginBottom: '0.5rem' }}>
<label className="progression-label" style={{ marginBottom: '0.25rem' }}>Остаток</label>
<div className="progression-input-wrapper">
<input
type="number"
step="any"
value={postponeRemaining}
onChange={(e) => setPostponeRemaining(e.target.value)}
placeholder={selectedItemForPostpone.estimated_remaining != null ? (Math.round(selectedItemForPostpone.estimated_remaining * 10) / 10).toString() : '0'}
className="progression-input"
style={{ paddingRight: postponeRemaining ? '2rem' : '0.75rem' }}
/>
{postponeRemaining && (
<button
type="button"
onClick={() => setPostponeRemaining('')}
style={{
position: 'absolute',
right: '8px',
top: '50%',
transform: 'translateY(-50%)',
background: 'none',
border: 'none',
color: '#9ca3af',
cursor: 'pointer',
fontSize: '1.1rem',
padding: '4px',
lineHeight: 1,
}}
>
</button>
)}
</div>
</div>
<div className="task-postpone-calendar"> <div className="task-postpone-calendar">
<DayPicker <DayPicker
mode="single" mode="single"
@@ -844,13 +908,21 @@ function ShoppingList({ onNavigate, refreshTrigger = 0, isActive = false, initia
Завтра Завтра
</button> </button>
)} )}
{!isCurrentDatePlanned && ( {selectedItemForPostpone.daily_consumption > 0 && (
<button <button
onClick={() => handlePostponeSubmitWithDate(plannedDateStr)} onClick={() => {
const remaining = postponeRemaining.trim() ? parseFloat(postponeRemaining) : (selectedItemForPostpone.estimated_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))
handlePostponeSubmitWithDate(formatDateToLocal(target))
}}
className="task-postpone-quick-button" className="task-postpone-quick-button"
disabled={isPostponing} disabled={isPostponing}
> >
По плану По остатку
</button> </button>
)} )}
{selectedItemForPostpone?.next_show_at && ( {selectedItemForPostpone?.next_show_at && (

View File

@@ -351,8 +351,9 @@
background: white; background: white;
border-radius: 0.5rem; border-radius: 0.5rem;
width: fit-content; width: fit-content;
max-width: 90%; max-width: min(90%, 350px);
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1); box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
overflow: hidden;
} }
.task-postpone-modal-header { .task-postpone-modal-header {