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
|
||||
}
|
||||
|
||||
// getShoppingMedianDailyConsumption возвращает медиану daily_consumption из volume_records для товара
|
||||
func (a *App) getShoppingMedianDailyConsumption(itemID int, volumeBase float64) float64 {
|
||||
var median sql.NullFloat64
|
||||
a.DB.QueryRow(`
|
||||
SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY daily_consumption)
|
||||
FROM shopping_volume_records
|
||||
WHERE item_id = $1 AND daily_consumption IS NOT NULL AND daily_consumption > 0
|
||||
`, itemID).Scan(&median)
|
||||
if median.Valid && median.Float64 > 0 {
|
||||
return median.Float64
|
||||
}
|
||||
// дефолт: volume_base хватает на 7 дней
|
||||
if volumeBase > 0 {
|
||||
return volumeBase / 7.0
|
||||
}
|
||||
return 1.0 / 7.0
|
||||
}
|
||||
|
||||
// getShoppingMedianPurchased возвращает медиану докупок для товара
|
||||
func (a *App) getShoppingMedianPurchased(itemID int) *float64 {
|
||||
var median sql.NullFloat64
|
||||
a.DB.QueryRow(`
|
||||
SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY volume_purchased)
|
||||
FROM shopping_volume_records
|
||||
WHERE item_id = $1 AND volume_purchased IS NOT NULL AND volume_purchased > 0
|
||||
`, itemID).Scan(&median)
|
||||
if median.Valid {
|
||||
return &median.Float64
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getShoppingLastVolumeRecord возвращает последнюю запись volume_records для товара
|
||||
type ShoppingVolumeRecord struct {
|
||||
ID int
|
||||
VolumeRemaining float64
|
||||
VolumePurchased float64
|
||||
DailyConsumption sql.NullFloat64
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func (a *App) getShoppingLastVolumeRecord(itemID int) *ShoppingVolumeRecord {
|
||||
var rec ShoppingVolumeRecord
|
||||
err := a.DB.QueryRow(`
|
||||
SELECT id, COALESCE(volume_remaining, 0), COALESCE(volume_purchased, 0), daily_consumption, created_at
|
||||
FROM shopping_volume_records
|
||||
WHERE item_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
`, itemID).Scan(&rec.ID, &rec.VolumeRemaining, &rec.VolumePurchased, &rec.DailyConsumption, &rec.CreatedAt)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &rec
|
||||
}
|
||||
|
||||
// calculateShoppingEstimatedRemaining вычисляет расчётный текущий остаток
|
||||
func calculateShoppingEstimatedRemaining(lastTotal float64, dailyConsumption float64, lastDate time.Time) float64 {
|
||||
daysPassed := time.Since(lastDate).Hours() / 24.0
|
||||
estimated := lastTotal - daysPassed*dailyConsumption
|
||||
if estimated < 0 {
|
||||
return 0
|
||||
}
|
||||
return estimated
|
||||
}
|
||||
|
||||
// calculateShoppingPeriodDailyConsumption вычисляет daily_consumption за период между двумя записями
|
||||
func calculateShoppingPeriodDailyConsumption(prevRemaining, prevPurchased float64, prevDate time.Time, currentRemaining float64) *float64 {
|
||||
prevTotal := prevRemaining + prevPurchased
|
||||
consumed := prevTotal - currentRemaining
|
||||
if consumed < 0 {
|
||||
return nil
|
||||
}
|
||||
days := time.Since(prevDate).Hours() / 24.0
|
||||
if days <= 0 {
|
||||
return nil
|
||||
}
|
||||
daily := consumed / days
|
||||
return &daily
|
||||
}
|
||||
|
||||
// enrichShoppingItemWithVolumeData добавляет расчётные поля к ShoppingItem
|
||||
func (a *App) enrichShoppingItemWithVolumeData(item *ShoppingItem) {
|
||||
lastRec := a.getShoppingLastVolumeRecord(item.ID)
|
||||
medianDaily := a.getShoppingMedianDailyConsumption(item.ID, item.VolumeBase)
|
||||
|
||||
item.DailyConsumption = &medianDaily
|
||||
|
||||
if lastRec != nil {
|
||||
lastTotal := lastRec.VolumeRemaining + lastRec.VolumePurchased
|
||||
estimated := calculateShoppingEstimatedRemaining(lastTotal, medianDaily, lastRec.CreatedAt)
|
||||
item.EstimatedRemaining = &estimated
|
||||
} else {
|
||||
zero := 0.0
|
||||
item.EstimatedRemaining = &zero
|
||||
}
|
||||
|
||||
item.MedianPurchased = a.getShoppingMedianPurchased(item.ID)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Auth types
|
||||
// ============================================
|
||||
@@ -4847,6 +4947,7 @@ func main() {
|
||||
protected.HandleFunc("/api/shopping/items/{id}/postpone", app.postponeShoppingItemHandler).Methods("POST", "OPTIONS")
|
||||
protected.HandleFunc("/api/shopping/items/{id}/history", app.getShoppingItemHistoryHandler).Methods("GET", "OPTIONS")
|
||||
protected.HandleFunc("/api/shopping/history/{id}", app.deleteShoppingItemHistoryHandler).Methods("DELETE", "OPTIONS")
|
||||
protected.HandleFunc("/api/shopping/items/{id}/volume-records", app.getShoppingVolumeRecordsHandler).Methods("GET", "OPTIONS")
|
||||
protected.HandleFunc("/api/shopping/groups", app.getShoppingGroupSuggestionsHandler).Methods("GET", "OPTIONS")
|
||||
protected.HandleFunc("/api/shopping/invite/{token}", app.getShoppingBoardInviteInfoHandler).Methods("GET", "OPTIONS")
|
||||
protected.HandleFunc("/api/shopping/invite/{token}/join", app.joinShoppingBoardHandler).Methods("POST", "OPTIONS")
|
||||
@@ -18926,20 +19027,23 @@ type ShoppingBoard struct {
|
||||
}
|
||||
|
||||
type ShoppingItem struct {
|
||||
ID int `json:"id"`
|
||||
UserID int `json:"user_id"`
|
||||
BoardID int `json:"board_id"`
|
||||
AuthorID int `json:"author_id"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
GroupName *string `json:"group_name,omitempty"`
|
||||
VolumeBase float64 `json:"volume_base"`
|
||||
RepetitionPeriod *string `json:"repetition_period,omitempty"`
|
||||
NextShowAt *string `json:"next_show_at,omitempty"`
|
||||
Completed int `json:"completed"`
|
||||
LastCompletedAt *string `json:"last_completed_at,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
LastVolume *float64 `json:"last_volume,omitempty"`
|
||||
ID int `json:"id"`
|
||||
UserID int `json:"user_id"`
|
||||
BoardID int `json:"board_id"`
|
||||
AuthorID int `json:"author_id"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
GroupName *string `json:"group_name,omitempty"`
|
||||
VolumeBase float64 `json:"volume_base"`
|
||||
RepetitionPeriod *string `json:"repetition_period,omitempty"`
|
||||
NextShowAt *string `json:"next_show_at,omitempty"`
|
||||
Completed int `json:"completed"`
|
||||
LastCompletedAt *string `json:"last_completed_at,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
LastVolume *float64 `json:"last_volume,omitempty"`
|
||||
DailyConsumption *float64 `json:"daily_consumption,omitempty"`
|
||||
EstimatedRemaining *float64 `json:"estimated_remaining,omitempty"`
|
||||
MedianPurchased *float64 `json:"median_purchased,omitempty"`
|
||||
}
|
||||
|
||||
type ShoppingItemRequest struct {
|
||||
@@ -18951,7 +19055,14 @@ type ShoppingItemRequest struct {
|
||||
}
|
||||
|
||||
type CompleteShoppingItemRequest struct {
|
||||
Volume *float64 `json:"volume,omitempty"`
|
||||
Volume *float64 `json:"volume,omitempty"`
|
||||
VolumeRemaining *float64 `json:"volume_remaining,omitempty"`
|
||||
VolumePurchased *float64 `json:"volume_purchased,omitempty"`
|
||||
}
|
||||
|
||||
type PostponeShoppingItemRequest struct {
|
||||
NextShowAt *string `json:"next_show_at"`
|
||||
VolumeRemaining *float64 `json:"volume_remaining,omitempty"`
|
||||
}
|
||||
|
||||
type ShoppingJoinBoardResponse struct {
|
||||
@@ -19741,6 +19852,9 @@ func (a *App) getShoppingItemsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
item.CreatedAt = createdAt.Format(time.RFC3339)
|
||||
|
||||
// Добавляем расчётные поля по объёму
|
||||
a.enrichShoppingItemWithVolumeData(&item)
|
||||
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
@@ -19816,6 +19930,15 @@ func (a *App) createShoppingItemHandler(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
// Создаём начальную запись в volume_records
|
||||
_, vrErr := a.DB.Exec(`
|
||||
INSERT INTO shopping_volume_records (item_id, user_id, action_type, volume_remaining, volume_purchased, created_at)
|
||||
VALUES ($1, $2, 'create', 0, 0, NOW())
|
||||
`, itemID, userID)
|
||||
if vrErr != nil {
|
||||
log.Printf("Error creating initial volume record: %v", vrErr)
|
||||
}
|
||||
|
||||
item := ShoppingItem{
|
||||
ID: itemID,
|
||||
UserID: boardOwnerID,
|
||||
@@ -19932,6 +20055,9 @@ func (a *App) getShoppingItemHandler(w http.ResponseWriter, r *http.Request) {
|
||||
item.LastVolume = &lastVolume.Float64
|
||||
}
|
||||
|
||||
// Добавляем расчётные поля по объёму
|
||||
a.enrichShoppingItemWithVolumeData(&item)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(item)
|
||||
}
|
||||
@@ -20210,55 +20336,83 @@ func (a *App) completeShoppingItemHandler(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
}
|
||||
|
||||
actualVolume := volumeBase
|
||||
if req.Volume != nil && *req.Volume > 0 {
|
||||
actualVolume = *req.Volume
|
||||
// Определяем volume_remaining и volume_purchased из нового или старого формата
|
||||
var volumeRemaining float64
|
||||
var volumePurchased float64
|
||||
if req.VolumeRemaining != nil {
|
||||
volumeRemaining = *req.VolumeRemaining
|
||||
}
|
||||
if req.VolumePurchased != nil {
|
||||
volumePurchased = *req.VolumePurchased
|
||||
} else if req.Volume != nil && *req.Volume > 0 {
|
||||
// Обратная совместимость: старый формат {volume} → считаем как докупку
|
||||
volumePurchased = *req.Volume
|
||||
} else {
|
||||
volumePurchased = volumeBase
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
newTotal := volumeRemaining + volumePurchased
|
||||
|
||||
if repetitionPeriod.Valid && repetitionPeriod.String != "" {
|
||||
// Рассчитываем next_show_at с учётом объёма
|
||||
multiplier := actualVolume / volumeBase
|
||||
baseNext := calculateNextShowAtFromRepetitionPeriod(repetitionPeriod.String, now)
|
||||
if baseNext != nil {
|
||||
// Применяем множитель: сдвигаем пропорционально объёму
|
||||
baseDuration := baseNext.Sub(time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()))
|
||||
adjustedDuration := time.Duration(float64(baseDuration) * multiplier)
|
||||
nextShowAt := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).Add(adjustedDuration)
|
||||
|
||||
_, err = a.DB.Exec(`
|
||||
UPDATE shopping_items
|
||||
SET completed = completed + 1, last_completed_at = $1, next_show_at = $2, updated_at = NOW()
|
||||
WHERE id = $3
|
||||
`, now, nextShowAt, itemID)
|
||||
} else {
|
||||
_, err = a.DB.Exec(`
|
||||
UPDATE shopping_items
|
||||
SET completed = completed + 1, last_completed_at = $1, updated_at = NOW()
|
||||
WHERE id = $2
|
||||
`, now, itemID)
|
||||
// Рассчитываем daily_consumption за текущий период
|
||||
lastRec := a.getShoppingLastVolumeRecord(itemID)
|
||||
var periodDaily *float64
|
||||
if lastRec != nil {
|
||||
prevTotal := lastRec.VolumeRemaining + lastRec.VolumePurchased
|
||||
consumed := prevTotal - volumeRemaining
|
||||
days := now.Sub(lastRec.CreatedAt).Hours() / 24.0
|
||||
if days > 0 && consumed >= 0 {
|
||||
d := consumed / days
|
||||
periodDaily = &d
|
||||
}
|
||||
} else {
|
||||
// Одноразовый товар - помечаем удалённым
|
||||
_, err = a.DB.Exec(`
|
||||
UPDATE shopping_items
|
||||
SET completed = completed + 1, last_completed_at = $1, deleted = TRUE, updated_at = NOW()
|
||||
WHERE id = $2
|
||||
`, now, itemID)
|
||||
}
|
||||
|
||||
// Записываем в volume_records
|
||||
_, vrErr := a.DB.Exec(`
|
||||
INSERT INTO shopping_volume_records (item_id, user_id, action_type, volume_remaining, volume_purchased, daily_consumption, created_at)
|
||||
VALUES ($1, $2, 'purchase', $3, $4, $5, $6)
|
||||
`, itemID, userID, volumeRemaining, volumePurchased, periodDaily, now)
|
||||
if vrErr != nil {
|
||||
log.Printf("Error inserting volume record: %v", vrErr)
|
||||
}
|
||||
|
||||
// Получаем медиану daily_consumption (включая новую запись)
|
||||
medianDaily := a.getShoppingMedianDailyConsumption(itemID, volumeBase)
|
||||
|
||||
// Рассчитываем next_show_at (показываем на 3 дня раньше чем закончится)
|
||||
var daysUntilEmpty float64
|
||||
if medianDaily > 0 {
|
||||
daysUntilEmpty = newTotal / medianDaily
|
||||
} else {
|
||||
daysUntilEmpty = 365 // cap
|
||||
}
|
||||
if daysUntilEmpty > 365 {
|
||||
daysUntilEmpty = 365
|
||||
}
|
||||
daysUntilShow := daysUntilEmpty - 3
|
||||
if daysUntilShow < 0 {
|
||||
daysUntilShow = 0
|
||||
}
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
nextShowAt := today.Add(time.Duration(daysUntilShow*24) * time.Hour)
|
||||
|
||||
_, err = a.DB.Exec(`
|
||||
UPDATE shopping_items
|
||||
SET completed = completed + 1, last_completed_at = $1, next_show_at = $2, updated_at = NOW()
|
||||
WHERE id = $3
|
||||
`, now, nextShowAt, itemID)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Error completing shopping item: %v", err)
|
||||
sendErrorWithCORS(w, "Error completing item", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Записываем в историю покупок
|
||||
// Записываем в историю покупок (обратная совместимость)
|
||||
_, histErr := a.DB.Exec(`
|
||||
INSERT INTO shopping_item_history (item_id, user_id, name, volume, completed_at)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
`, itemID, userID, itemName, actualVolume, now)
|
||||
`, itemID, userID, itemName, volumePurchased, now)
|
||||
if histErr != nil {
|
||||
log.Printf("Error inserting shopping item history: %v", histErr)
|
||||
}
|
||||
@@ -20292,15 +20446,16 @@ func (a *App) postponeShoppingItemHandler(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
|
||||
var req PostponeTaskRequest
|
||||
var req PostponeShoppingItemRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Проверяем что товар существует и получаем board_id
|
||||
// Проверяем что товар существует и получаем board_id и volume_base
|
||||
var boardID int
|
||||
err = a.DB.QueryRow(`SELECT board_id FROM shopping_items WHERE id = $1 AND deleted = FALSE`, itemID).Scan(&boardID)
|
||||
var volumeBase float64
|
||||
err = a.DB.QueryRow(`SELECT board_id, volume_base FROM shopping_items WHERE id = $1 AND deleted = FALSE`, itemID).Scan(&boardID, &volumeBase)
|
||||
if err == sql.ErrNoRows {
|
||||
sendErrorWithCORS(w, "Item not found", http.StatusNotFound)
|
||||
return
|
||||
@@ -20319,6 +20474,7 @@ func (a *App) postponeShoppingItemHandler(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
}
|
||||
|
||||
// Парсим next_show_at
|
||||
var nextShowAtValue interface{}
|
||||
if req.NextShowAt == nil || *req.NextShowAt == "" {
|
||||
nextShowAtValue = nil
|
||||
@@ -20331,6 +20487,30 @@ func (a *App) postponeShoppingItemHandler(w http.ResponseWriter, r *http.Request
|
||||
nextShowAtValue = nextShowAt
|
||||
}
|
||||
|
||||
// Если передан volume_remaining — записываем в volume_records
|
||||
if req.VolumeRemaining != nil {
|
||||
now := time.Now()
|
||||
lastRec := a.getShoppingLastVolumeRecord(itemID)
|
||||
var periodDaily *float64
|
||||
if lastRec != nil {
|
||||
prevTotal := lastRec.VolumeRemaining + lastRec.VolumePurchased
|
||||
consumed := prevTotal - *req.VolumeRemaining
|
||||
days := now.Sub(lastRec.CreatedAt).Hours() / 24.0
|
||||
if days > 0 && consumed >= 0 {
|
||||
d := consumed / days
|
||||
periodDaily = &d
|
||||
}
|
||||
}
|
||||
|
||||
_, vrErr := a.DB.Exec(`
|
||||
INSERT INTO shopping_volume_records (item_id, user_id, action_type, volume_remaining, volume_purchased, daily_consumption, next_show_at, created_at)
|
||||
VALUES ($1, $2, 'postpone', $3, 0, $4, $5, $6)
|
||||
`, itemID, userID, *req.VolumeRemaining, periodDaily, nextShowAtValue, now)
|
||||
if vrErr != nil {
|
||||
log.Printf("Error inserting postpone volume record: %v", vrErr)
|
||||
}
|
||||
}
|
||||
|
||||
_, err = a.DB.Exec(`
|
||||
UPDATE shopping_items
|
||||
SET next_show_at = $1, updated_at = NOW()
|
||||
@@ -20552,6 +20732,102 @@ func (a *App) deleteShoppingItemHistoryHandler(w http.ResponseWriter, r *http.Re
|
||||
})
|
||||
}
|
||||
|
||||
// getShoppingVolumeRecordsHandler возвращает записи об остатках товара
|
||||
func (a *App) getShoppingVolumeRecordsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "OPTIONS" {
|
||||
setCORSHeaders(w)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
setCORSHeaders(w)
|
||||
|
||||
userID, ok := getUserIDFromContext(r)
|
||||
if !ok {
|
||||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
itemID, err := strconv.Atoi(vars["id"])
|
||||
if err != nil {
|
||||
sendErrorWithCORS(w, "Invalid item ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Получаем board_id товара для проверки доступа
|
||||
var boardID int
|
||||
err = a.DB.QueryRow(`SELECT board_id FROM shopping_items WHERE id = $1`, itemID).Scan(&boardID)
|
||||
if err == sql.ErrNoRows {
|
||||
sendErrorWithCORS(w, "Item not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
sendErrorWithCORS(w, "Error getting item", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Проверяем доступ
|
||||
var ownerID int
|
||||
a.DB.QueryRow(`SELECT owner_id FROM shopping_boards WHERE id = $1`, boardID).Scan(&ownerID)
|
||||
if ownerID != userID {
|
||||
var isMember bool
|
||||
a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM shopping_board_members WHERE board_id = $1 AND user_id = $2)`,
|
||||
boardID, userID).Scan(&isMember)
|
||||
if !isMember {
|
||||
sendErrorWithCORS(w, "Access denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := a.DB.Query(`
|
||||
SELECT id, action_type, COALESCE(volume_remaining, 0), COALESCE(volume_purchased, 0), daily_consumption, next_show_at, created_at
|
||||
FROM shopping_volume_records
|
||||
WHERE item_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 50
|
||||
`, itemID)
|
||||
if err != nil {
|
||||
log.Printf("Error getting volume records: %v", err)
|
||||
sendErrorWithCORS(w, "Error getting volume records", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type VolumeRecordEntry struct {
|
||||
ID int `json:"id"`
|
||||
ActionType string `json:"action_type"`
|
||||
VolumeRemaining float64 `json:"volume_remaining"`
|
||||
VolumePurchased float64 `json:"volume_purchased"`
|
||||
DailyConsumption *float64 `json:"daily_consumption,omitempty"`
|
||||
NextShowAt *string `json:"next_show_at,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
records := []VolumeRecordEntry{}
|
||||
for rows.Next() {
|
||||
var entry VolumeRecordEntry
|
||||
var dailyConsumption sql.NullFloat64
|
||||
var nextShowAt sql.NullTime
|
||||
var createdAt time.Time
|
||||
if err := rows.Scan(&entry.ID, &entry.ActionType, &entry.VolumeRemaining, &entry.VolumePurchased, &dailyConsumption, &nextShowAt, &createdAt); err != nil {
|
||||
log.Printf("Error scanning volume record: %v", err)
|
||||
continue
|
||||
}
|
||||
if dailyConsumption.Valid {
|
||||
entry.DailyConsumption = &dailyConsumption.Float64
|
||||
}
|
||||
if nextShowAt.Valid {
|
||||
s := nextShowAt.Time.Format(time.RFC3339)
|
||||
entry.NextShowAt = &s
|
||||
}
|
||||
entry.CreatedAt = createdAt.Format(time.RFC3339)
|
||||
records = append(records, entry)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(records)
|
||||
}
|
||||
|
||||
// getPurchaseBoardsInfoHandler возвращает доски пользователя с их группами для формы закупок
|
||||
func (a *App) getPurchaseBoardsInfoHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "OPTIONS" {
|
||||
@@ -20798,6 +21074,9 @@ func (a *App) getPurchaseItemsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
item.CreatedAt = createdAt.Format(time.RFC3339)
|
||||
|
||||
// Добавляем расчётные поля по объёму
|
||||
a.enrichShoppingItemWithVolumeData(&item)
|
||||
|
||||
items = append(items, item)
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
@@ -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",
|
||||
"version": "6.21.3",
|
||||
"version": "6.22.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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,42 +179,78 @@ function ShoppingItemDetail({ itemId, onClose, onRefresh, onItemCompleted, onNav
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="shopping-item-complete-row">
|
||||
<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'}
|
||||
className="progression-input"
|
||||
/>
|
||||
<div className="progression-controls-capsule">
|
||||
<button
|
||||
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 step = item.volume_base || 1
|
||||
setVolumeValue((current - step).toString())
|
||||
}}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<button
|
||||
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 step = item.volume_base || 1
|
||||
setVolumeValue((current + step).toString())
|
||||
}}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<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={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">
|
||||
<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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,90 +86,70 @@ 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="Удалить"
|
||||
{records.map((record) => {
|
||||
const total = (record.volume_remaining || 0) + (record.volume_purchased || 0)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={record.id}
|
||||
className="bg-white rounded-lg p-4 shadow-sm border border-gray-200"
|
||||
>
|
||||
{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>
|
||||
<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>
|
||||
)}
|
||||
</button>
|
||||
<div className="text-gray-800 pr-8">
|
||||
{entry.name}
|
||||
</div>
|
||||
<div className="text-gray-800 font-semibold mt-1">
|
||||
Объём: {formatVolume(entry.volume)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-2">
|
||||
{formatDate(entry.completed_at)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{toastMessage && (
|
||||
<Toast
|
||||
message={toastMessage.text}
|
||||
type={toastMessage.type}
|
||||
onClose={() => setToastMessage(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user