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
}
// getShoppingMedianDailyConsumption возвращает медиану daily_consumption из volume_records для товара
func (a *App) getShoppingMedianDailyConsumption(itemID int, volumeBase float64) float64 {
var median sql.NullFloat64
a.DB.QueryRow(`
SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY daily_consumption)
FROM shopping_volume_records
WHERE item_id = $1 AND daily_consumption IS NOT NULL AND daily_consumption > 0
`, itemID).Scan(&median)
if median.Valid && median.Float64 > 0 {
return median.Float64
}
// дефолт: volume_base хватает на 7 дней
if volumeBase > 0 {
return volumeBase / 7.0
}
return 1.0 / 7.0
}
// getShoppingMedianPurchased возвращает медиану докупок для товара
func (a *App) getShoppingMedianPurchased(itemID int) *float64 {
var median sql.NullFloat64
a.DB.QueryRow(`
SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY volume_purchased)
FROM shopping_volume_records
WHERE item_id = $1 AND volume_purchased IS NOT NULL AND volume_purchased > 0
`, itemID).Scan(&median)
if median.Valid {
return &median.Float64
}
return nil
}
// getShoppingLastVolumeRecord возвращает последнюю запись volume_records для товара
type ShoppingVolumeRecord struct {
ID int
VolumeRemaining float64
VolumePurchased float64
DailyConsumption sql.NullFloat64
CreatedAt time.Time
}
func (a *App) getShoppingLastVolumeRecord(itemID int) *ShoppingVolumeRecord {
var rec ShoppingVolumeRecord
err := a.DB.QueryRow(`
SELECT id, COALESCE(volume_remaining, 0), COALESCE(volume_purchased, 0), daily_consumption, created_at
FROM shopping_volume_records
WHERE item_id = $1
ORDER BY created_at DESC
LIMIT 1
`, itemID).Scan(&rec.ID, &rec.VolumeRemaining, &rec.VolumePurchased, &rec.DailyConsumption, &rec.CreatedAt)
if err != nil {
return nil
}
return &rec
}
// calculateShoppingEstimatedRemaining вычисляет расчётный текущий остаток
func calculateShoppingEstimatedRemaining(lastTotal float64, dailyConsumption float64, lastDate time.Time) float64 {
daysPassed := time.Since(lastDate).Hours() / 24.0
estimated := lastTotal - daysPassed*dailyConsumption
if estimated < 0 {
return 0
}
return estimated
}
// calculateShoppingPeriodDailyConsumption вычисляет daily_consumption за период между двумя записями
func calculateShoppingPeriodDailyConsumption(prevRemaining, prevPurchased float64, prevDate time.Time, currentRemaining float64) *float64 {
prevTotal := prevRemaining + prevPurchased
consumed := prevTotal - currentRemaining
if consumed < 0 {
return nil
}
days := time.Since(prevDate).Hours() / 24.0
if days <= 0 {
return nil
}
daily := consumed / days
return &daily
}
// enrichShoppingItemWithVolumeData добавляет расчётные поля к ShoppingItem
func (a *App) enrichShoppingItemWithVolumeData(item *ShoppingItem) {
lastRec := a.getShoppingLastVolumeRecord(item.ID)
medianDaily := a.getShoppingMedianDailyConsumption(item.ID, item.VolumeBase)
item.DailyConsumption = &medianDaily
if lastRec != nil {
lastTotal := lastRec.VolumeRemaining + lastRec.VolumePurchased
estimated := calculateShoppingEstimatedRemaining(lastTotal, medianDaily, lastRec.CreatedAt)
item.EstimatedRemaining = &estimated
} else {
zero := 0.0
item.EstimatedRemaining = &zero
}
item.MedianPurchased = a.getShoppingMedianPurchased(item.ID)
}
// ============================================
// Auth types
// ============================================
@@ -4847,6 +4947,7 @@ func main() {
protected.HandleFunc("/api/shopping/items/{id}/postpone", app.postponeShoppingItemHandler).Methods("POST", "OPTIONS")
protected.HandleFunc("/api/shopping/items/{id}/history", app.getShoppingItemHistoryHandler).Methods("GET", "OPTIONS")
protected.HandleFunc("/api/shopping/history/{id}", app.deleteShoppingItemHistoryHandler).Methods("DELETE", "OPTIONS")
protected.HandleFunc("/api/shopping/items/{id}/volume-records", app.getShoppingVolumeRecordsHandler).Methods("GET", "OPTIONS")
protected.HandleFunc("/api/shopping/groups", app.getShoppingGroupSuggestionsHandler).Methods("GET", "OPTIONS")
protected.HandleFunc("/api/shopping/invite/{token}", app.getShoppingBoardInviteInfoHandler).Methods("GET", "OPTIONS")
protected.HandleFunc("/api/shopping/invite/{token}/join", app.joinShoppingBoardHandler).Methods("POST", "OPTIONS")
@@ -18940,6 +19041,9 @@ type ShoppingItem struct {
LastCompletedAt *string `json:"last_completed_at,omitempty"`
CreatedAt string `json:"created_at"`
LastVolume *float64 `json:"last_volume,omitempty"`
DailyConsumption *float64 `json:"daily_consumption,omitempty"`
EstimatedRemaining *float64 `json:"estimated_remaining,omitempty"`
MedianPurchased *float64 `json:"median_purchased,omitempty"`
}
type ShoppingItemRequest struct {
@@ -18952,6 +19056,13 @@ type ShoppingItemRequest struct {
type CompleteShoppingItemRequest struct {
Volume *float64 `json:"volume,omitempty"`
VolumeRemaining *float64 `json:"volume_remaining,omitempty"`
VolumePurchased *float64 `json:"volume_purchased,omitempty"`
}
type PostponeShoppingItemRequest struct {
NextShowAt *string `json:"next_show_at"`
VolumeRemaining *float64 `json:"volume_remaining,omitempty"`
}
type ShoppingJoinBoardResponse struct {
@@ -19741,6 +19852,9 @@ func (a *App) getShoppingItemsHandler(w http.ResponseWriter, r *http.Request) {
}
item.CreatedAt = createdAt.Format(time.RFC3339)
// Добавляем расчётные поля по объёму
a.enrichShoppingItemWithVolumeData(&item)
items = append(items, item)
}
@@ -19816,6 +19930,15 @@ func (a *App) createShoppingItemHandler(w http.ResponseWriter, r *http.Request)
return
}
// Создаём начальную запись в volume_records
_, vrErr := a.DB.Exec(`
INSERT INTO shopping_volume_records (item_id, user_id, action_type, volume_remaining, volume_purchased, created_at)
VALUES ($1, $2, 'create', 0, 0, NOW())
`, itemID, userID)
if vrErr != nil {
log.Printf("Error creating initial volume record: %v", vrErr)
}
item := ShoppingItem{
ID: itemID,
UserID: boardOwnerID,
@@ -19932,6 +20055,9 @@ func (a *App) getShoppingItemHandler(w http.ResponseWriter, r *http.Request) {
item.LastVolume = &lastVolume.Float64
}
// Добавляем расчётные поля по объёму
a.enrichShoppingItemWithVolumeData(&item)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(item)
}
@@ -20210,43 +20336,71 @@ func (a *App) completeShoppingItemHandler(w http.ResponseWriter, r *http.Request
}
}
actualVolume := volumeBase
if req.Volume != nil && *req.Volume > 0 {
actualVolume = *req.Volume
// Определяем volume_remaining и volume_purchased из нового или старого формата
var volumeRemaining float64
var volumePurchased float64
if req.VolumeRemaining != nil {
volumeRemaining = *req.VolumeRemaining
}
if req.VolumePurchased != nil {
volumePurchased = *req.VolumePurchased
} else if req.Volume != nil && *req.Volume > 0 {
// Обратная совместимость: старый формат {volume} → считаем как докупку
volumePurchased = *req.Volume
} else {
volumePurchased = volumeBase
}
now := time.Now()
newTotal := volumeRemaining + volumePurchased
if repetitionPeriod.Valid && repetitionPeriod.String != "" {
// Рассчитываем next_show_at с учётом объёма
multiplier := actualVolume / volumeBase
baseNext := calculateNextShowAtFromRepetitionPeriod(repetitionPeriod.String, now)
if baseNext != nil {
// Применяем множитель: сдвигаем пропорционально объёму
baseDuration := baseNext.Sub(time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()))
adjustedDuration := time.Duration(float64(baseDuration) * multiplier)
nextShowAt := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).Add(adjustedDuration)
// Рассчитываем daily_consumption за текущий период
lastRec := a.getShoppingLastVolumeRecord(itemID)
var periodDaily *float64
if lastRec != nil {
prevTotal := lastRec.VolumeRemaining + lastRec.VolumePurchased
consumed := prevTotal - volumeRemaining
days := now.Sub(lastRec.CreatedAt).Hours() / 24.0
if days > 0 && consumed >= 0 {
d := consumed / days
periodDaily = &d
}
}
// Записываем в 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)
} 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 {
log.Printf("Error completing shopping item: %v", err)
@@ -20254,11 +20408,11 @@ func (a *App) completeShoppingItemHandler(w http.ResponseWriter, r *http.Request
return
}
// Записываем в историю покупок
// Записываем в историю покупок (обратная совместимость)
_, histErr := a.DB.Exec(`
INSERT INTO shopping_item_history (item_id, user_id, name, volume, completed_at)
VALUES ($1, $2, $3, $4, $5)
`, itemID, userID, itemName, actualVolume, now)
`, itemID, userID, itemName, volumePurchased, now)
if histErr != nil {
log.Printf("Error inserting shopping item history: %v", histErr)
}
@@ -20292,15 +20446,16 @@ func (a *App) postponeShoppingItemHandler(w http.ResponseWriter, r *http.Request
return
}
var req PostponeTaskRequest
var req PostponeShoppingItemRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest)
return
}
// Проверяем что товар существует и получаем board_id
// Проверяем что товар существует и получаем board_id и volume_base
var boardID int
err = a.DB.QueryRow(`SELECT board_id FROM shopping_items WHERE id = $1 AND deleted = FALSE`, itemID).Scan(&boardID)
var volumeBase float64
err = a.DB.QueryRow(`SELECT board_id, volume_base FROM shopping_items WHERE id = $1 AND deleted = FALSE`, itemID).Scan(&boardID, &volumeBase)
if err == sql.ErrNoRows {
sendErrorWithCORS(w, "Item not found", http.StatusNotFound)
return
@@ -20319,6 +20474,7 @@ func (a *App) postponeShoppingItemHandler(w http.ResponseWriter, r *http.Request
}
}
// Парсим next_show_at
var nextShowAtValue interface{}
if req.NextShowAt == nil || *req.NextShowAt == "" {
nextShowAtValue = nil
@@ -20331,6 +20487,30 @@ func (a *App) postponeShoppingItemHandler(w http.ResponseWriter, r *http.Request
nextShowAtValue = nextShowAt
}
// Если передан volume_remaining — записываем в volume_records
if req.VolumeRemaining != nil {
now := time.Now()
lastRec := a.getShoppingLastVolumeRecord(itemID)
var periodDaily *float64
if lastRec != nil {
prevTotal := lastRec.VolumeRemaining + lastRec.VolumePurchased
consumed := prevTotal - *req.VolumeRemaining
days := now.Sub(lastRec.CreatedAt).Hours() / 24.0
if days > 0 && consumed >= 0 {
d := consumed / days
periodDaily = &d
}
}
_, vrErr := a.DB.Exec(`
INSERT INTO shopping_volume_records (item_id, user_id, action_type, volume_remaining, volume_purchased, daily_consumption, next_show_at, created_at)
VALUES ($1, $2, 'postpone', $3, 0, $4, $5, $6)
`, itemID, userID, *req.VolumeRemaining, periodDaily, nextShowAtValue, now)
if vrErr != nil {
log.Printf("Error inserting postpone volume record: %v", vrErr)
}
}
_, err = a.DB.Exec(`
UPDATE shopping_items
SET next_show_at = $1, updated_at = NOW()
@@ -20552,6 +20732,102 @@ func (a *App) deleteShoppingItemHistoryHandler(w http.ResponseWriter, r *http.Re
})
}
// getShoppingVolumeRecordsHandler возвращает записи об остатках товара
func (a *App) getShoppingVolumeRecordsHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" {
setCORSHeaders(w)
w.WriteHeader(http.StatusOK)
return
}
setCORSHeaders(w)
userID, ok := getUserIDFromContext(r)
if !ok {
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
return
}
vars := mux.Vars(r)
itemID, err := strconv.Atoi(vars["id"])
if err != nil {
sendErrorWithCORS(w, "Invalid item ID", http.StatusBadRequest)
return
}
// Получаем board_id товара для проверки доступа
var boardID int
err = a.DB.QueryRow(`SELECT board_id FROM shopping_items WHERE id = $1`, itemID).Scan(&boardID)
if err == sql.ErrNoRows {
sendErrorWithCORS(w, "Item not found", http.StatusNotFound)
return
}
if err != nil {
sendErrorWithCORS(w, "Error getting item", http.StatusInternalServerError)
return
}
// Проверяем доступ
var ownerID int
a.DB.QueryRow(`SELECT owner_id FROM shopping_boards WHERE id = $1`, boardID).Scan(&ownerID)
if ownerID != userID {
var isMember bool
a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM shopping_board_members WHERE board_id = $1 AND user_id = $2)`,
boardID, userID).Scan(&isMember)
if !isMember {
sendErrorWithCORS(w, "Access denied", http.StatusForbidden)
return
}
}
rows, err := a.DB.Query(`
SELECT id, action_type, COALESCE(volume_remaining, 0), COALESCE(volume_purchased, 0), daily_consumption, next_show_at, created_at
FROM shopping_volume_records
WHERE item_id = $1
ORDER BY created_at DESC
LIMIT 50
`, itemID)
if err != nil {
log.Printf("Error getting volume records: %v", err)
sendErrorWithCORS(w, "Error getting volume records", http.StatusInternalServerError)
return
}
defer rows.Close()
type VolumeRecordEntry struct {
ID int `json:"id"`
ActionType string `json:"action_type"`
VolumeRemaining float64 `json:"volume_remaining"`
VolumePurchased float64 `json:"volume_purchased"`
DailyConsumption *float64 `json:"daily_consumption,omitempty"`
NextShowAt *string `json:"next_show_at,omitempty"`
CreatedAt string `json:"created_at"`
}
records := []VolumeRecordEntry{}
for rows.Next() {
var entry VolumeRecordEntry
var dailyConsumption sql.NullFloat64
var nextShowAt sql.NullTime
var createdAt time.Time
if err := rows.Scan(&entry.ID, &entry.ActionType, &entry.VolumeRemaining, &entry.VolumePurchased, &dailyConsumption, &nextShowAt, &createdAt); err != nil {
log.Printf("Error scanning volume record: %v", err)
continue
}
if dailyConsumption.Valid {
entry.DailyConsumption = &dailyConsumption.Float64
}
if nextShowAt.Valid {
s := nextShowAt.Time.Format(time.RFC3339)
entry.NextShowAt = &s
}
entry.CreatedAt = createdAt.Format(time.RFC3339)
records = append(records, entry)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(records)
}
// getPurchaseBoardsInfoHandler возвращает доски пользователя с их группами для формы закупок
func (a *App) getPurchaseBoardsInfoHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" {
@@ -20798,6 +21074,9 @@ func (a *App) getPurchaseItemsHandler(w http.ResponseWriter, r *http.Request) {
}
item.CreatedAt = createdAt.Format(time.RFC3339)
// Добавляем расчётные поля по объёму
a.enrichShoppingItemWithVolumeData(&item)
items = append(items, item)
}
rows.Close()

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",
"version": "6.21.3",
"version": "6.22.0",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -7,6 +7,7 @@ import { DayPicker } from 'react-day-picker'
import { ru } from 'react-day-picker/locale'
import 'react-day-picker/style.css'
import './TaskList.css'
import './TaskDetail.css'
import './ShoppingList.css'
// Форматирование даты в YYYY-MM-DD (локальное время)
@@ -59,6 +60,7 @@ function PurchaseScreen({ onNavigate, purchaseConfigId, taskId, taskName }) {
const [selectedItemForDetail, setSelectedItemForDetail] = useState(null)
const [selectedItemForPostpone, setSelectedItemForPostpone] = useState(null)
const [postponeDate, setPostponeDate] = useState('')
const [postponeRemaining, setPostponeRemaining] = useState('')
const [isPostponing, setIsPostponing] = useState(false)
const [toast, setToast] = useState(null)
const [expandedFuture, setExpandedFuture] = useState({})
@@ -153,6 +155,7 @@ function PurchaseScreen({ onNavigate, purchaseConfigId, taskId, taskName }) {
if (currentPostpone) {
setSelectedItemForPostpone(null)
setPostponeDate('')
setPostponeRemaining('')
historyPushedForPostponeRef.current = false
return
}
@@ -246,6 +249,7 @@ function PurchaseScreen({ onNavigate, purchaseConfigId, taskId, taskName }) {
} else {
setSelectedItemForPostpone(null)
setPostponeDate('')
setPostponeRemaining('')
}
}
@@ -254,10 +258,14 @@ function PurchaseScreen({ onNavigate, purchaseConfigId, taskId, taskName }) {
setIsPostponing(true)
try {
const nextShowAt = new Date(dateStr + 'T00:00:00')
const payload = { next_show_at: nextShowAt.toISOString() }
if (postponeRemaining.trim()) {
payload.volume_remaining = parseFloat(postponeRemaining)
}
const res = await authFetch(`/api/shopping/items/${selectedItemForPostpone.id}/postpone`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ next_show_at: nextShowAt.toISOString() })
body: JSON.stringify(payload)
})
if (res.ok) {
setToast({ message: 'Дата обновлена', type: 'success' })
@@ -299,10 +307,14 @@ function PurchaseScreen({ onNavigate, purchaseConfigId, taskId, taskName }) {
if (!selectedItemForPostpone) return
setIsPostponing(true)
try {
const payload = { next_show_at: null }
if (postponeRemaining.trim()) {
payload.volume_remaining = parseFloat(postponeRemaining)
}
const res = await authFetch(`/api/shopping/items/${selectedItemForPostpone.id}/postpone`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ next_show_at: null })
body: JSON.stringify(payload)
})
if (res.ok) {
setToast({ message: 'Дата убрана', type: 'success' })
@@ -316,6 +328,28 @@ function PurchaseScreen({ onNavigate, purchaseConfigId, taskId, taskName }) {
}
}
// Автообновление даты в календаре при вводе остатка
useEffect(() => {
if (!selectedItemForPostpone) return
if (selectedItemForPostpone.daily_consumption > 0) {
const remaining = postponeRemaining.trim() ? parseFloat(postponeRemaining) : (selectedItemForPostpone.estimated_remaining ?? 0)
if (!isNaN(remaining) && remaining >= 0) {
const daily = selectedItemForPostpone.daily_consumption
const daysLeft = remaining / daily
const target = new Date()
target.setHours(0, 0, 0, 0)
target.setDate(target.getDate() + Math.ceil(daysLeft))
setPostponeDate(formatDateToLocal(target))
return
}
}
// Фолбэк: завтра
const tomorrow = new Date()
tomorrow.setHours(0, 0, 0, 0)
tomorrow.setDate(tomorrow.getDate() + 1)
setPostponeDate(formatDateToLocal(tomorrow))
}, [postponeRemaining, selectedItemForPostpone])
const renderItem = (item) => {
let dateDisplay = null
if (item.next_show_at) {
@@ -350,7 +384,14 @@ function PurchaseScreen({ onNavigate, purchaseConfigId, taskId, taskName }) {
</div>
<div className="task-name-container">
<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 && (
<div className="task-next-show-date">{dateDisplay}</div>
)}
@@ -362,28 +403,8 @@ function PurchaseScreen({ onNavigate, purchaseConfigId, taskId, taskName }) {
onClick={(e) => {
e.stopPropagation()
setSelectedItemForPostpone(item)
// Предвыбираем дату "по плану" если она не совпадает с текущей next_show_at
const now2 = new Date()
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('')
}
setPostponeRemaining(item.estimated_remaining != null && item.estimated_remaining > 0 ? (Math.round(item.estimated_remaining * 10) / 10).toString() : '')
// Дата рассчитывается в useEffect по остатку
}}
title="Перенести"
>
@@ -565,6 +586,41 @@ function PurchaseScreen({ onNavigate, purchaseConfigId, taskId, taskName }) {
</button>
</div>
<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">
<DayPicker
mode="single"
@@ -598,13 +654,21 @@ function PurchaseScreen({ onNavigate, purchaseConfigId, taskId, taskName }) {
Завтра
</button>
)}
{!isCurrentDatePlanned && (
{selectedItemForPostpone.daily_consumption > 0 && (
<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}
>
По плану
По остатку
</button>
)}
{nextShowAtStr && (

View File

@@ -10,7 +10,8 @@ function ShoppingItemDetail({ itemId, onClose, onRefresh, onItemCompleted, onNav
const [item, setItem] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [volumeValue, setVolumeValue] = useState('')
const [volumeRemaining, setVolumeRemaining] = useState('')
const [volumePurchased, setVolumePurchased] = useState('')
const [isCompleting, setIsCompleting] = useState(false)
const [toastMessage, setToastMessage] = useState(null)
@@ -38,7 +39,8 @@ function ShoppingItemDetail({ itemId, onClose, onRefresh, onItemCompleted, onNav
setItem(null)
setLoading(true)
setError(null)
setVolumeValue('')
setVolumeRemaining('')
setVolumePurchased('')
}
}, [itemId, fetchItem])
@@ -48,13 +50,21 @@ function ShoppingItemDetail({ itemId, onClose, onRefresh, onItemCompleted, onNav
setIsCompleting(true)
try {
const payload = {}
if (volumeValue.trim()) {
payload.volume = parseFloat(volumeValue)
if (isNaN(payload.volume)) {
throw new Error('Неверное значение объёма')
if (volumeRemaining.trim()) {
payload.volume_remaining = parseFloat(volumeRemaining)
if (isNaN(payload.volume_remaining)) {
throw new Error('Неверное значение остатка')
}
} 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`, {
@@ -169,15 +179,50 @@ function ShoppingItemDetail({ itemId, onClose, onRefresh, onItemCompleted, onNav
</button>
</div>
<div className="shopping-item-complete-row">
<label className="progression-label">Объём</label>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'flex-end', marginBottom: '0.75rem' }}>
<div style={{ flex: 1 }}>
<label className="progression-label">Остаток</label>
<div className="progression-input-wrapper">
<input
type="number"
step="any"
value={volumeValue}
onChange={(e) => setVolumeValue(e.target.value)}
placeholder={(item.last_volume ?? item.volume_base)?.toString() || '1'}
value={volumeRemaining}
onChange={(e) => setVolumeRemaining(e.target.value)}
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"
/>
<div className="progression-controls-capsule">
@@ -185,10 +230,10 @@ function ShoppingItemDetail({ itemId, onClose, onRefresh, onItemCompleted, onNav
type="button"
className="progression-control-btn progression-control-minus"
onClick={() => {
const base = item.last_volume ?? item.volume_base ?? 1
const current = volumeValue.trim() ? parseFloat(volumeValue) : base
const base = item.median_purchased ?? item.volume_base ?? 1
const current = volumePurchased.trim() ? parseFloat(volumePurchased) : base
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"
className="progression-control-btn progression-control-plus"
onClick={() => {
const base = item.last_volume ?? item.volume_base ?? 1
const current = volumeValue.trim() ? parseFloat(volumeValue) : base
const base = item.median_purchased ?? item.volume_base ?? 1
const current = volumePurchased.trim() ? parseFloat(volumePurchased) : base
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 className="task-action-left">
<button
onClick={handleComplete}

View File

@@ -13,8 +13,6 @@ function ShoppingItemForm({ onNavigate, itemId, boardId, previousTab, onSaved, i
const [groupName, setGroupName] = useState('')
const [groupSuggestions, setGroupSuggestions] = useState([])
const [volumeBase, setVolumeBase] = useState('')
const [repetitionPeriodValue, setRepetitionPeriodValue] = useState('')
const [repetitionPeriodType, setRepetitionPeriodType] = useState('day')
const [loading, setLoading] = useState(false)
const [loadingItem, setLoadingItem] = 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) {
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 {
setToastMessage({ text: 'Ошибка загрузки товара', type: 'error' })
}
@@ -95,28 +73,14 @@ function ShoppingItemForm({ onNavigate, itemId, boardId, previousTab, onSaved, i
return
}
if (!hasValidPeriod) {
setToastMessage({ text: 'Укажите период повторения', type: 'error' })
return
}
setLoading(true)
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 payload = {
name: name.trim(),
description: description.trim() || null,
group_name: groupName.trim() || null,
volume_base: vb && vb > 0 ? vb : null,
repetition_period: repetitionPeriod,
}
let url, method
@@ -224,8 +188,6 @@ function ShoppingItemForm({ onNavigate, itemId, boardId, previousTab, onSaved, i
window.history.back()
}
const hasValidPeriod = repetitionPeriodValue && repetitionPeriodValue.trim() !== '' && parseInt(repetitionPeriodValue.trim(), 10) > 0
if (loadingItem) {
return (
<div className="shopping-item-form">
@@ -292,7 +254,7 @@ function ShoppingItemForm({ onNavigate, itemId, boardId, previousTab, onSaved, i
</div>
<div className="form-group">
<label htmlFor="item-volume">Объём</label>
<label htmlFor="item-volume">Шаги объёма</label>
<input
id="item-volume"
type="number"
@@ -305,36 +267,6 @@ function ShoppingItemForm({ onNavigate, itemId, boardId, previousTab, onSaved, i
/>
</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>
{toastMessage && (
@@ -361,7 +293,7 @@ function ShoppingItemForm({ onNavigate, itemId, boardId, previousTab, onSaved, i
}}>
<button
onClick={handleSave}
disabled={loading || isDeleting || isCopying || !name.trim() || !hasValidPeriod}
disabled={loading || isDeleting || isCopying || !name.trim()}
style={{
flex: 1,
maxWidth: '42rem',

View File

@@ -1,28 +1,25 @@
import React, { useState, useEffect, useCallback } from 'react'
import { useAuth } from './auth/AuthContext'
import LoadingError from './LoadingError'
import Toast from './Toast'
import './Integrations.css'
function ShoppingItemHistory({ itemId, onNavigate }) {
const { authFetch } = useAuth()
const [history, setHistory] = useState([])
const [records, setRecords] = useState([])
const [loading, setLoading] = useState(true)
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
try {
setLoading(true)
setError(null)
const response = await authFetch(`/api/shopping/items/${itemId}/history`)
const response = await authFetch(`/api/shopping/items/${itemId}/volume-records`)
if (!response.ok) {
throw new Error('Ошибка загрузки истории')
}
const data = await response.json()
setHistory(Array.isArray(data) ? data : [])
setRecords(Array.isArray(data) ? data : [])
} catch (err) {
setError(err.message)
} finally {
@@ -31,26 +28,8 @@ function ShoppingItemHistory({ itemId, onNavigate }) {
}, [itemId, authFetch])
useEffect(() => {
fetchHistory()
}, [fetchHistory])
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)
}
}
fetchRecords()
}, [fetchRecords])
const formatDate = (dateStr) => {
const date = new Date(dateStr)
@@ -64,11 +43,29 @@ function ShoppingItemHistory({ itemId, onNavigate }) {
}
const formatVolume = (volume) => {
if (volume === 1) return '1'
const rounded = Math.round(volume * 10000) / 10000
if (volume == null) return ''
const rounded = Math.round(volume * 10) / 10
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 (
<div className="max-w-2xl mx-auto">
{onNavigate && (
@@ -89,89 +86,69 @@ function ShoppingItemHistory({ itemId, onNavigate }) {
</div>
</div>
) : error ? (
<LoadingError onRetry={fetchHistory} />
) : history.length === 0 ? (
<LoadingError onRetry={fetchRecords} />
) : 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="text-gray-500 text-lg">История пуста</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">
{history.map((entry) => (
<div
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>
)}
{records.map((record) => {
const total = (record.volume_remaining || 0) + (record.volume_purchased || 0)
{toastMessage && (
<Toast
message={toastMessage.text}
type={toastMessage.type}
onClose={() => setToastMessage(null)}
/>
return (
<div
key={record.id}
className="bg-white rounded-lg p-4 shadow-sm border border-gray-200"
>
<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>
)

View File

@@ -9,6 +9,7 @@ import { DayPicker } from 'react-day-picker'
import { ru } from 'react-day-picker/locale'
import 'react-day-picker/style.css'
import './TaskList.css'
import './TaskDetail.css'
import './ShoppingList.css'
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 [selectedItemForPostpone, setSelectedItemForPostpone] = useState(null)
const [postponeDate, setPostponeDate] = useState('')
const [postponeRemaining, setPostponeRemaining] = useState('')
const [isPostponing, setIsPostponing] = useState(false)
const [toast, setToast] = useState(null)
const initialFetchDoneRef = useRef(false)
@@ -319,6 +321,7 @@ function ShoppingList({ onNavigate, refreshTrigger = 0, isActive = false, initia
if (currentPostpone) {
setSelectedItemForPostpone(null)
setPostponeDate('')
setPostponeRemaining('')
historyPushedForPostponeRef.current = false
if (currentDetail) {
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) => {
setSelectedItemForPostpone(item)
// Предвыбираем дату "по плану" если она не совпадает с текущей next_show_at
const now2 = new Date()
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('')
}
const remainingVal = item.estimated_remaining != null && item.estimated_remaining > 0 ? (Math.round(item.estimated_remaining * 10) / 10).toString() : ''
setPostponeRemaining(remainingVal)
// Дата рассчитывается в useEffect по остатку
}
// Модалка переноса
@@ -452,6 +436,7 @@ function ShoppingList({ onNavigate, refreshTrigger = 0, isActive = false, initia
historyPushedForPostponeRef.current = false
setSelectedItemForPostpone(null)
setPostponeDate('')
setPostponeRemaining('')
}
}
@@ -474,10 +459,14 @@ function ShoppingList({ onNavigate, refreshTrigger = 0, isActive = false, initia
setIsPostponing(true)
try {
const nextShowAt = new Date(dateStr + 'T00:00:00')
const payload = { next_show_at: nextShowAt.toISOString() }
if (postponeRemaining.trim()) {
payload.volume_remaining = parseFloat(postponeRemaining)
}
const res = await authFetch(`/api/shopping/items/${selectedItemForPostpone.id}/postpone`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ next_show_at: nextShowAt.toISOString() })
body: JSON.stringify(payload)
})
if (res.ok) {
setToast({ message: 'Дата перенесена', type: 'success' })
@@ -507,10 +496,14 @@ function ShoppingList({ onNavigate, refreshTrigger = 0, isActive = false, initia
if (!selectedItemForPostpone) return
setIsPostponing(true)
try {
const payload = { next_show_at: null }
if (postponeRemaining.trim()) {
payload.volume_remaining = parseFloat(postponeRemaining)
}
const res = await authFetch(`/api/shopping/items/${selectedItemForPostpone.id}/postpone`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ next_show_at: null })
body: JSON.stringify(payload)
})
if (res.ok) {
setToast({ message: 'Дата убрана', type: 'success' })
@@ -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 names = Object.keys(groupedItems)
return names.sort((a, b) => {
@@ -658,7 +673,14 @@ function ShoppingList({ onNavigate, refreshTrigger = 0, isActive = false, initia
</div>
<div className="task-name-container">
<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 && (
<div className="task-next-show-date">{dateDisplay}</div>
)}
@@ -722,7 +744,14 @@ function ShoppingList({ onNavigate, refreshTrigger = 0, isActive = false, initia
</div>
<div className="task-name-container">
<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 && (
<div className="task-next-show-date">{dateDisplay}</div>
)}
@@ -811,6 +840,41 @@ function ShoppingList({ onNavigate, refreshTrigger = 0, isActive = false, initia
</button>
</div>
<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">
<DayPicker
mode="single"
@@ -844,13 +908,21 @@ function ShoppingList({ onNavigate, refreshTrigger = 0, isActive = false, initia
Завтра
</button>
)}
{!isCurrentDatePlanned && (
{selectedItemForPostpone.daily_consumption > 0 && (
<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"
disabled={isPostponing}
>
По плану
По остатку
</button>
)}
{selectedItemForPostpone?.next_show_at && (

View File

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