6.7.0: Описание товаров и редизайн выполнения
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m24s
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m24s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -17732,6 +17732,7 @@ type ShoppingItem struct {
|
|||||||
BoardID int `json:"board_id"`
|
BoardID int `json:"board_id"`
|
||||||
AuthorID int `json:"author_id"`
|
AuthorID int `json:"author_id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
Description *string `json:"description,omitempty"`
|
||||||
GroupName *string `json:"group_name,omitempty"`
|
GroupName *string `json:"group_name,omitempty"`
|
||||||
VolumeBase float64 `json:"volume_base"`
|
VolumeBase float64 `json:"volume_base"`
|
||||||
RepetitionPeriod *string `json:"repetition_period,omitempty"`
|
RepetitionPeriod *string `json:"repetition_period,omitempty"`
|
||||||
@@ -17743,6 +17744,7 @@ type ShoppingItem struct {
|
|||||||
|
|
||||||
type ShoppingItemRequest struct {
|
type ShoppingItemRequest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
Description *string `json:"description,omitempty"`
|
||||||
GroupName *string `json:"group_name,omitempty"`
|
GroupName *string `json:"group_name,omitempty"`
|
||||||
VolumeBase *float64 `json:"volume_base,omitempty"`
|
VolumeBase *float64 `json:"volume_base,omitempty"`
|
||||||
RepetitionPeriod *string `json:"repetition_period,omitempty"`
|
RepetitionPeriod *string `json:"repetition_period,omitempty"`
|
||||||
@@ -18487,7 +18489,7 @@ func (a *App) getShoppingItemsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
items := []ShoppingItem{}
|
items := []ShoppingItem{}
|
||||||
rows, err := a.DB.Query(`
|
rows, err := a.DB.Query(`
|
||||||
SELECT
|
SELECT
|
||||||
si.id, si.user_id, si.board_id, si.author_id, si.name, si.group_name,
|
si.id, si.user_id, si.board_id, si.author_id, si.name, si.description, si.group_name,
|
||||||
si.volume_base, si.repetition_period::text, si.next_show_at, si.completed,
|
si.volume_base, si.repetition_period::text, si.next_show_at, si.completed,
|
||||||
si.last_completed_at, si.created_at
|
si.last_completed_at, si.created_at
|
||||||
FROM shopping_items si
|
FROM shopping_items si
|
||||||
@@ -18503,6 +18505,7 @@ func (a *App) getShoppingItemsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var item ShoppingItem
|
var item ShoppingItem
|
||||||
|
var description sql.NullString
|
||||||
var groupName sql.NullString
|
var groupName sql.NullString
|
||||||
var repetitionPeriod sql.NullString
|
var repetitionPeriod sql.NullString
|
||||||
var nextShowAt sql.NullTime
|
var nextShowAt sql.NullTime
|
||||||
@@ -18510,7 +18513,7 @@ func (a *App) getShoppingItemsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
var createdAt time.Time
|
var createdAt time.Time
|
||||||
|
|
||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&item.ID, &item.UserID, &item.BoardID, &item.AuthorID, &item.Name, &groupName,
|
&item.ID, &item.UserID, &item.BoardID, &item.AuthorID, &item.Name, &description, &groupName,
|
||||||
&item.VolumeBase, &repetitionPeriod, &nextShowAt, &item.Completed,
|
&item.VolumeBase, &repetitionPeriod, &nextShowAt, &item.Completed,
|
||||||
&lastCompletedAt, &createdAt,
|
&lastCompletedAt, &createdAt,
|
||||||
)
|
)
|
||||||
@@ -18519,6 +18522,9 @@ func (a *App) getShoppingItemsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if description.Valid {
|
||||||
|
item.Description = &description.String
|
||||||
|
}
|
||||||
if groupName.Valid {
|
if groupName.Valid {
|
||||||
item.GroupName = &groupName.String
|
item.GroupName = &groupName.String
|
||||||
}
|
}
|
||||||
@@ -18599,10 +18605,10 @@ func (a *App) createShoppingItemHandler(w http.ResponseWriter, r *http.Request)
|
|||||||
|
|
||||||
var itemID int
|
var itemID int
|
||||||
err = a.DB.QueryRow(`
|
err = a.DB.QueryRow(`
|
||||||
INSERT INTO shopping_items (user_id, board_id, author_id, name, group_name, volume_base, repetition_period)
|
INSERT INTO shopping_items (user_id, board_id, author_id, name, description, group_name, volume_base, repetition_period)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7::interval)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::interval)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`, boardOwnerID, boardID, userID, strings.TrimSpace(req.Name), req.GroupName, volumeBase, req.RepetitionPeriod).Scan(&itemID)
|
`, boardOwnerID, boardID, userID, strings.TrimSpace(req.Name), req.Description, req.GroupName, volumeBase, req.RepetitionPeriod).Scan(&itemID)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error creating shopping item: %v", err)
|
log.Printf("Error creating shopping item: %v", err)
|
||||||
@@ -18616,6 +18622,7 @@ func (a *App) createShoppingItemHandler(w http.ResponseWriter, r *http.Request)
|
|||||||
BoardID: boardID,
|
BoardID: boardID,
|
||||||
AuthorID: userID,
|
AuthorID: userID,
|
||||||
Name: strings.TrimSpace(req.Name),
|
Name: strings.TrimSpace(req.Name),
|
||||||
|
Description: req.Description,
|
||||||
GroupName: req.GroupName,
|
GroupName: req.GroupName,
|
||||||
VolumeBase: volumeBase,
|
VolumeBase: volumeBase,
|
||||||
RepetitionPeriod: req.RepetitionPeriod,
|
RepetitionPeriod: req.RepetitionPeriod,
|
||||||
@@ -18651,6 +18658,7 @@ func (a *App) getShoppingItemHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var item ShoppingItem
|
var item ShoppingItem
|
||||||
|
var description sql.NullString
|
||||||
var groupName sql.NullString
|
var groupName sql.NullString
|
||||||
var repetitionPeriod sql.NullString
|
var repetitionPeriod sql.NullString
|
||||||
var nextShowAt sql.NullTime
|
var nextShowAt sql.NullTime
|
||||||
@@ -18659,13 +18667,13 @@ func (a *App) getShoppingItemHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
err = a.DB.QueryRow(`
|
err = a.DB.QueryRow(`
|
||||||
SELECT
|
SELECT
|
||||||
si.id, si.user_id, si.board_id, si.author_id, si.name, si.group_name,
|
si.id, si.user_id, si.board_id, si.author_id, si.name, si.description, si.group_name,
|
||||||
si.volume_base, si.repetition_period::text, si.next_show_at, si.completed,
|
si.volume_base, si.repetition_period::text, si.next_show_at, si.completed,
|
||||||
si.last_completed_at, si.created_at
|
si.last_completed_at, si.created_at
|
||||||
FROM shopping_items si
|
FROM shopping_items si
|
||||||
WHERE si.id = $1 AND si.deleted = FALSE
|
WHERE si.id = $1 AND si.deleted = FALSE
|
||||||
`, itemID).Scan(
|
`, itemID).Scan(
|
||||||
&item.ID, &item.UserID, &item.BoardID, &item.AuthorID, &item.Name, &groupName,
|
&item.ID, &item.UserID, &item.BoardID, &item.AuthorID, &item.Name, &description, &groupName,
|
||||||
&item.VolumeBase, &repetitionPeriod, &nextShowAt, &item.Completed,
|
&item.VolumeBase, &repetitionPeriod, &nextShowAt, &item.Completed,
|
||||||
&lastCompletedAt, &createdAt,
|
&lastCompletedAt, &createdAt,
|
||||||
)
|
)
|
||||||
@@ -18693,6 +18701,9 @@ func (a *App) getShoppingItemHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if description.Valid {
|
||||||
|
item.Description = &description.String
|
||||||
|
}
|
||||||
if groupName.Valid {
|
if groupName.Valid {
|
||||||
item.GroupName = &groupName.String
|
item.GroupName = &groupName.String
|
||||||
}
|
}
|
||||||
@@ -18774,9 +18785,9 @@ func (a *App) updateShoppingItemHandler(w http.ResponseWriter, r *http.Request)
|
|||||||
|
|
||||||
_, err = a.DB.Exec(`
|
_, err = a.DB.Exec(`
|
||||||
UPDATE shopping_items
|
UPDATE shopping_items
|
||||||
SET name = $1, group_name = $2, volume_base = $3, repetition_period = $4::interval, updated_at = NOW()
|
SET name = $1, description = $2, group_name = $3, volume_base = $4, repetition_period = $5::interval, updated_at = NOW()
|
||||||
WHERE id = $5
|
WHERE id = $6
|
||||||
`, strings.TrimSpace(req.Name), req.GroupName, volumeBase, req.RepetitionPeriod, itemID)
|
`, strings.TrimSpace(req.Name), req.Description, req.GroupName, volumeBase, req.RepetitionPeriod, itemID)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error updating shopping item: %v", err)
|
log.Printf("Error updating shopping item: %v", err)
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE shopping_items DROP COLUMN description;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE shopping_items ADD COLUMN description TEXT;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "play-life-web",
|
"name": "play-life-web",
|
||||||
"version": "6.6.4",
|
"version": "6.7.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -127,8 +127,44 @@ function ShoppingItemDetail({ itemId, onClose, onRefresh, onItemCompleted, onNav
|
|||||||
|
|
||||||
{!loading && !error && item && (
|
{!loading && !error && item && (
|
||||||
<>
|
<>
|
||||||
<div className="progression-section">
|
{item.description && (
|
||||||
<label className="progression-label">Объём</label>
|
<div className="shopping-item-description-card">
|
||||||
|
<div className="shopping-item-description">
|
||||||
|
{item.description.split(/(https?:\/\/[^\s<>"'`,;!)\]]+)/gi).map((part, i) => {
|
||||||
|
if (/^https?:\/\//i.test(part)) {
|
||||||
|
let host
|
||||||
|
try {
|
||||||
|
host = new URL(part).host.replace(/^www\./, '')
|
||||||
|
} catch {
|
||||||
|
host = 'Открыть ссылку'
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<a key={i} href={part} target="_blank" rel="noopener noreferrer" className="shopping-item-description-link">
|
||||||
|
{host}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return <span key={i}>{part}</span>
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="shopping-item-history-button"
|
||||||
|
onClick={() => {}}
|
||||||
|
title="История"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
|
||||||
|
<polyline points="14 2 14 8 20 8"></polyline>
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13"></line>
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17"></line>
|
||||||
|
<polyline points="10 9 9 9 8 9"></polyline>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="shopping-item-complete-row">
|
||||||
<div className="progression-input-wrapper">
|
<div className="progression-input-wrapper">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -163,22 +199,19 @@ function ShoppingItemDetail({ itemId, onClose, onRefresh, onItemCompleted, onNav
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<button
|
||||||
|
onClick={handleComplete}
|
||||||
<div className="task-detail-divider"></div>
|
disabled={isCompleting}
|
||||||
|
className="shopping-item-complete-button"
|
||||||
<div className="task-actions-section">
|
>
|
||||||
<div className="task-actions-buttons">
|
{isCompleting ? (
|
||||||
<div className="task-action-left">
|
<div className="shopping-item-complete-spinner"></div>
|
||||||
<button
|
) : (
|
||||||
onClick={handleComplete}
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
disabled={isCompleting}
|
<polyline points="20 6 9 17 4 12"></polyline>
|
||||||
className="action-button action-button-check"
|
</svg>
|
||||||
>
|
)}
|
||||||
{isCompleting ? 'Выполнение...' : 'Выполнить'}
|
</button>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import './ShoppingItemForm.css'
|
|||||||
function ShoppingItemForm({ onNavigate, itemId, boardId, onSaved }) {
|
function ShoppingItemForm({ onNavigate, itemId, boardId, onSaved }) {
|
||||||
const { authFetch } = useAuth()
|
const { authFetch } = useAuth()
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
|
const [description, setDescription] = useState('')
|
||||||
const [groupName, setGroupName] = useState('')
|
const [groupName, setGroupName] = useState('')
|
||||||
const [groupSuggestions, setGroupSuggestions] = useState([])
|
const [groupSuggestions, setGroupSuggestions] = useState([])
|
||||||
const [volumeBase, setVolumeBase] = useState('')
|
const [volumeBase, setVolumeBase] = useState('')
|
||||||
@@ -49,6 +50,7 @@ function ShoppingItemForm({ onNavigate, itemId, boardId, onSaved }) {
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
setName(data.name)
|
setName(data.name)
|
||||||
|
setDescription(data.description || '')
|
||||||
setGroupName(data.group_name || '')
|
setGroupName(data.group_name || '')
|
||||||
if (data.volume_base && data.volume_base !== 1) {
|
if (data.volume_base && data.volume_base !== 1) {
|
||||||
setVolumeBase(data.volume_base.toString())
|
setVolumeBase(data.volume_base.toString())
|
||||||
@@ -107,6 +109,7 @@ function ShoppingItemForm({ onNavigate, itemId, boardId, onSaved }) {
|
|||||||
const vb = volumeBase.trim() ? parseFloat(volumeBase.trim()) : null
|
const vb = volumeBase.trim() ? parseFloat(volumeBase.trim()) : null
|
||||||
const payload = {
|
const payload = {
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
|
description: description.trim() || null,
|
||||||
group_name: groupName.trim() || null,
|
group_name: groupName.trim() || null,
|
||||||
volume_base: vb && vb > 0 ? vb : null,
|
volume_base: vb && vb > 0 ? vb : null,
|
||||||
repetition_period: repetitionPeriod,
|
repetition_period: repetitionPeriod,
|
||||||
@@ -200,6 +203,18 @@ function ShoppingItemForm({ onNavigate, itemId, boardId, onSaved }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="item-description">Описание</label>
|
||||||
|
<textarea
|
||||||
|
id="item-description"
|
||||||
|
className="form-input"
|
||||||
|
value={description}
|
||||||
|
onChange={e => setDescription(e.target.value)}
|
||||||
|
placeholder="Описание товара"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="item-group">Группа</label>
|
<label htmlFor="item-group">Группа</label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -389,8 +389,8 @@ function ShoppingList({ onNavigate, refreshTrigger = 0, isActive = false, initia
|
|||||||
if (selectedBoardId) fetchItems(selectedBoardId)
|
if (selectedBoardId) fetchItems(selectedBoardId)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCloseDetail = () => {
|
const handleCloseDetail = (skipHistoryBack = false) => {
|
||||||
if (historyPushedForDetailRef.current) {
|
if (!skipHistoryBack && historyPushedForDetailRef.current) {
|
||||||
window.history.back()
|
window.history.back()
|
||||||
} else {
|
} else {
|
||||||
historyPushedForDetailRef.current = false
|
historyPushedForDetailRef.current = false
|
||||||
|
|||||||
@@ -157,6 +157,103 @@
|
|||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.shopping-item-description-card {
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shopping-item-description {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #374151;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shopping-item-history-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #9ca3af;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shopping-item-history-button:hover {
|
||||||
|
color: #4f46e5;
|
||||||
|
background-color: rgba(79, 70, 229, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shopping-item-description-link {
|
||||||
|
color: #3498db;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shopping-item-description-link:hover {
|
||||||
|
color: #2980b9;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shopping-item-complete-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shopping-item-complete-row .progression-input-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shopping-item-complete-button {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
border: none;
|
||||||
|
background: linear-gradient(135deg, #10b981, #059669);
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: all 0.2s;
|
||||||
|
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shopping-item-complete-button:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shopping-item-complete-button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shopping-item-complete-spinner {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 2.5px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-top-color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.6s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
.progression-section {
|
.progression-section {
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user