6.22.0: Авторасчёт сроков товаров по истории
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m23s
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:
@@ -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")
|
||||||
@@ -18926,20 +19027,23 @@ type ShoppingBoard struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ShoppingItem struct {
|
type ShoppingItem struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
UserID int `json:"user_id"`
|
UserID int `json:"user_id"`
|
||||||
BoardID int `json:"board_id"`
|
BoardID int `json:"board_id"`
|
||||||
AuthorID int `json:"author_id"`
|
AuthorID int `json:"author_id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description *string `json:"description,omitempty"`
|
Description *string `json:"description,omitempty"`
|
||||||
GroupName *string `json:"group_name,omitempty"`
|
GroupName *string `json:"group_name,omitempty"`
|
||||||
VolumeBase float64 `json:"volume_base"`
|
VolumeBase float64 `json:"volume_base"`
|
||||||
RepetitionPeriod *string `json:"repetition_period,omitempty"`
|
RepetitionPeriod *string `json:"repetition_period,omitempty"`
|
||||||
NextShowAt *string `json:"next_show_at,omitempty"`
|
NextShowAt *string `json:"next_show_at,omitempty"`
|
||||||
Completed int `json:"completed"`
|
Completed int `json:"completed"`
|
||||||
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 {
|
||||||
@@ -18951,7 +19055,14 @@ 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,55 +20336,83 @@ 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
|
||||||
_, 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)
|
|
||||||
}
|
}
|
||||||
} 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 {
|
if err != nil {
|
||||||
log.Printf("Error completing shopping item: %v", err)
|
log.Printf("Error completing shopping item: %v", err)
|
||||||
sendErrorWithCORS(w, "Error completing item", http.StatusInternalServerError)
|
sendErrorWithCORS(w, "Error completing item", http.StatusInternalServerError)
|
||||||
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()
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS shopping_volume_records;
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE shopping_volume_records DROP COLUMN IF EXISTS next_show_at;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE shopping_volume_records ADD COLUMN next_show_at TIMESTAMP;
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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,42 +179,78 @@ 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 }}>
|
||||||
<div className="progression-input-wrapper">
|
<label className="progression-label">Остаток</label>
|
||||||
<input
|
<div className="progression-input-wrapper">
|
||||||
type="number"
|
<input
|
||||||
step="any"
|
type="number"
|
||||||
value={volumeValue}
|
step="any"
|
||||||
onChange={(e) => setVolumeValue(e.target.value)}
|
value={volumeRemaining}
|
||||||
placeholder={(item.last_volume ?? item.volume_base)?.toString() || '1'}
|
onChange={(e) => setVolumeRemaining(e.target.value)}
|
||||||
className="progression-input"
|
placeholder={item.estimated_remaining != null ? Math.round(item.estimated_remaining * 10) / 10 + '' : '0'}
|
||||||
/>
|
className="progression-input"
|
||||||
<div className="progression-controls-capsule">
|
/>
|
||||||
<button
|
{volumeRemaining && (
|
||||||
type="button"
|
<button
|
||||||
className="progression-control-btn progression-control-minus"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => setVolumeRemaining('')}
|
||||||
const base = item.last_volume ?? item.volume_base ?? 1
|
style={{
|
||||||
const current = volumeValue.trim() ? parseFloat(volumeValue) : base
|
position: 'absolute',
|
||||||
const step = item.volume_base || 1
|
right: '8px',
|
||||||
setVolumeValue((current - step).toString())
|
top: '50%',
|
||||||
}}
|
transform: 'translateY(-50%)',
|
||||||
>
|
background: 'none',
|
||||||
−
|
border: 'none',
|
||||||
</button>
|
color: '#9ca3af',
|
||||||
<button
|
cursor: 'pointer',
|
||||||
type="button"
|
fontSize: '1.1rem',
|
||||||
className="progression-control-btn progression-control-plus"
|
padding: '4px',
|
||||||
onClick={() => {
|
lineHeight: 1,
|
||||||
const base = item.last_volume ?? item.volume_base ?? 1
|
}}
|
||||||
const current = volumeValue.trim() ? parseFloat(volumeValue) : base
|
>
|
||||||
const step = item.volume_base || 1
|
✕
|
||||||
setVolumeValue((current + step).toString())
|
</button>
|
||||||
}}
|
)}
|
||||||
>
|
</div>
|
||||||
+
|
</div>
|
||||||
</button>
|
<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"
|
||||||
|
/>
|
||||||
|
<div className="progression-controls-capsule">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="progression-control-btn progression-control-minus"
|
||||||
|
onClick={() => {
|
||||||
|
const base = item.median_purchased ?? item.volume_base ?? 1
|
||||||
|
const current = volumePurchased.trim() ? parseFloat(volumePurchased) : base
|
||||||
|
const step = item.volume_base || 1
|
||||||
|
setVolumePurchased(Math.max(0, current - step).toString())
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="progression-control-btn progression-control-plus"
|
||||||
|
onClick={() => {
|
||||||
|
const base = item.median_purchased ?? item.volume_base ?? 1
|
||||||
|
const current = volumePurchased.trim() ? parseFloat(volumePurchased) : base
|
||||||
|
const step = item.volume_base || 1
|
||||||
|
setVolumePurchased((current + step).toString())
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,90 +86,70 @@ 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"
|
return (
|
||||||
>
|
<div
|
||||||
<button
|
key={record.id}
|
||||||
onClick={() => handleDelete(entry.id)}
|
className="bg-white rounded-lg p-4 shadow-sm border border-gray-200"
|
||||||
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 ? (
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<svg className="w-5 h-5 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
<span style={{ color: getActionColor(record.action_type), fontWeight: 600, fontSize: '0.9rem' }}>
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
{getActionLabel(record.action_type)}
|
||||||
<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>
|
</span>
|
||||||
</svg>
|
<span className="text-xs text-gray-500">
|
||||||
) : (
|
{formatDate(record.created_at)}
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
</span>
|
||||||
<path d="M3 6h18"></path>
|
</div>
|
||||||
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path>
|
{record.action_type === 'purchase' && (
|
||||||
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path>
|
<div className="text-gray-800 mt-2">
|
||||||
</svg>
|
{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>
|
||||||
)}
|
)}
|
||||||
</button>
|
|
||||||
<div className="text-gray-800 pr-8">
|
|
||||||
{entry.name}
|
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{toastMessage && (
|
|
||||||
<Toast
|
|
||||||
message={toastMessage.text}
|
|
||||||
type={toastMessage.type}
|
|
||||||
onClose={() => setToastMessage(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user