feat: order comments, duplicate button, improved status timeline
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+120
-11
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, Save, FileText, Camera, X as XIcon, Share2, MessageSquare } from 'lucide-react';
|
||||
import { X, Save, FileText, Camera, X as XIcon, Share2, MessageSquare, MessageCircle, Copy } from 'lucide-react';
|
||||
import { useOrderPhotos } from './useOrderPhotos';
|
||||
import { useOrderComments } from './useOrderComments';
|
||||
import { pdf } from '@react-pdf/renderer';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { useAuth } from '../auth/useAuth';
|
||||
@@ -90,12 +91,14 @@ const OrderModal = ({ order, onClose, onSaved, mode = 'edit' }) => {
|
||||
});
|
||||
const [events, setEvents] = useState([]);
|
||||
const { photos, uploading, uploadPhotos, deletePhoto } = useOrderPhotos(mode === 'edit' ? order?.id : null);
|
||||
const { comments, loading: commentsLoading, addComment } = useOrderComments(mode === 'edit' ? order?.id : null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [templatesOpen, setTemplatesOpen] = useState(false);
|
||||
const [copiedTpl, setCopiedTpl] = useState(null);
|
||||
const [commentText, setCommentText] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (order?.id) {
|
||||
@@ -151,6 +154,29 @@ const OrderModal = ({ order, onClose, onSaved, mode = 'edit' }) => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddComment = async () => {
|
||||
if (!commentText.trim()) return;
|
||||
await addComment(session?.user?.email || 'Аноним', commentText.trim());
|
||||
setCommentText('');
|
||||
};
|
||||
|
||||
const handleDuplicate = async () => {
|
||||
const { name, phone, object_type, area_sqm, film_thickness, final_cost, notes, assigned_to, address, telegram_username, max_username } = form;
|
||||
const { error } = await supabase.from('orders').insert({
|
||||
name, phone, object_type,
|
||||
area_sqm: area_sqm === '' ? null : Number(area_sqm),
|
||||
film_thickness,
|
||||
final_cost: final_cost === '' ? null : Number(final_cost),
|
||||
notes, assigned_to, address, telegram_username, max_username,
|
||||
status: 'новая', source: 'manual',
|
||||
});
|
||||
if (!error) {
|
||||
onSaved();
|
||||
} else {
|
||||
setError(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.name.trim() || !form.phone.trim()) {
|
||||
setError('Имя и телефон обязательны');
|
||||
@@ -266,6 +292,23 @@ const OrderModal = ({ order, onClose, onSaved, mode = 'edit' }) => {
|
||||
{mode === 'create' ? 'Новая заявка' : order?.name}
|
||||
</h2>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
{mode === 'edit' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDuplicate}
|
||||
title="Создать копию заявки"
|
||||
style={{
|
||||
background: 'none', border: '1.5px solid var(--border-light)',
|
||||
borderRadius: '6px', padding: '5px 10px',
|
||||
cursor: 'pointer', color: 'var(--text-muted)',
|
||||
display: 'inline-flex', alignItems: 'center', gap: '5px',
|
||||
fontSize: '0.78rem', fontFamily: 'var(--font-heading)',
|
||||
}}
|
||||
>
|
||||
<Copy size={13} />
|
||||
Дублировать
|
||||
</button>
|
||||
)}
|
||||
{mode === 'edit' && order?.public_token && (
|
||||
<button
|
||||
onClick={handleShare}
|
||||
@@ -430,6 +473,60 @@ const OrderModal = ({ order, onClose, onSaved, mode = 'edit' }) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'edit' && (
|
||||
<div style={{ marginTop: '24px', borderTop: '1px solid var(--border-light)', paddingTop: '20px' }}>
|
||||
<h3 style={{ fontFamily: 'var(--font-heading)', fontSize: '0.9rem', fontWeight: 700, marginBottom: '12px', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<MessageCircle size={16} color="var(--accent-blue)" />
|
||||
Комментарии {comments.length > 0 && <span style={{ fontSize: '0.75rem', background: '#EEF2FF', color: '#4361EE', padding: '1px 7px', borderRadius: '10px', fontWeight: 700 }}>{comments.length}</span>}
|
||||
</h3>
|
||||
|
||||
{/* Comment list */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', marginBottom: '14px' }}>
|
||||
{comments.length === 0 && (
|
||||
<p style={{ fontSize: '0.82rem', color: 'var(--text-muted)' }}>Комментариев пока нет</p>
|
||||
)}
|
||||
{comments.map((c) => (
|
||||
<div key={c.id} style={{
|
||||
background: 'var(--bg-lighter)', border: '1px solid var(--border-light)',
|
||||
borderRadius: '8px', padding: '10px 14px',
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '4px' }}>
|
||||
<span style={{ fontSize: '0.78rem', fontWeight: 700, color: '#4361EE' }}>{c.author}</span>
|
||||
<span style={{ fontSize: '0.72rem', color: 'var(--text-muted)' }}>
|
||||
{new Date(c.created_at).toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
</div>
|
||||
<p style={{ fontSize: '0.85rem', color: 'var(--text-main)', lineHeight: 1.5, margin: 0 }}>{c.body}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add comment */}
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<input
|
||||
value={commentText}
|
||||
onChange={(e) => setCommentText(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && handleAddComment()}
|
||||
placeholder="Добавить комментарий... (Enter для отправки)"
|
||||
style={{ ...inputStyle, flex: 1 }}
|
||||
disabled={commentsLoading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddComment}
|
||||
disabled={!commentText.trim() || commentsLoading}
|
||||
style={{
|
||||
padding: '10px 16px', borderRadius: '6px', background: '#4361EE', color: '#fff',
|
||||
border: 'none', cursor: 'pointer', fontWeight: 700, fontSize: '0.85rem',
|
||||
fontFamily: 'var(--font-heading)', opacity: !commentText.trim() ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
↵
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'edit' && (
|
||||
<div style={{ marginTop: '14px' }}>
|
||||
<label style={labelStyle}>Фотографии объекта</label>
|
||||
@@ -523,16 +620,28 @@ const OrderModal = ({ order, onClose, onSaved, mode = 'edit' }) => {
|
||||
<h3 style={{ fontFamily: 'var(--font-heading)', fontSize: '0.9rem', fontWeight: 700, marginBottom: '12px' }}>
|
||||
История изменений
|
||||
</h3>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{events.map((e) => (
|
||||
<div key={e.id} style={{ fontSize: '0.8rem', color: 'var(--text-muted)', display: 'flex', gap: '10px', alignItems: 'baseline' }}>
|
||||
<span style={{ color: 'var(--accent-blue)', fontWeight: 600, flexShrink: 0 }}>
|
||||
{new Date(e.created_at).toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
<span style={{ flex: 1 }}>{e.description}</span>
|
||||
{e.created_by && (
|
||||
<span style={{ opacity: 0.55, flexShrink: 0, fontSize: '0.72rem' }}>{e.created_by}</span>
|
||||
)}
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{events.map((e, i) => (
|
||||
<div key={e.id} style={{ display: 'flex', gap: '12px', alignItems: 'flex-start' }}>
|
||||
{/* Timeline dot + line */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', flexShrink: 0 }}>
|
||||
<div style={{
|
||||
width: '10px', height: '10px', borderRadius: '50%', marginTop: '4px',
|
||||
background: e.event_type === 'status_change' ? '#4361EE' : '#E9ECEF',
|
||||
border: '2px solid #fff', boxShadow: '0 0 0 1.5px #4361EE',
|
||||
flexShrink: 0,
|
||||
}} />
|
||||
{i < events.length - 1 && (
|
||||
<div style={{ width: '1px', flex: 1, background: '#E9ECEF', minHeight: '20px', marginTop: '2px' }} />
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flex: 1, paddingBottom: i < events.length - 1 ? '14px' : 0 }}>
|
||||
<div style={{ fontSize: '0.82rem', color: 'var(--text-main)', lineHeight: 1.4 }}>{e.description}</div>
|
||||
<div style={{ fontSize: '0.7rem', color: 'var(--text-muted)', marginTop: '2px', display: 'flex', gap: '8px' }}>
|
||||
<span>{new Date(e.created_at).toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}</span>
|
||||
{e.created_by && <span>· {e.created_by}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
|
||||
export function useOrderComments(orderId) {
|
||||
const [comments, setComments] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetch = useCallback(async () => {
|
||||
if (!orderId) return;
|
||||
const { data } = await supabase
|
||||
.from('order_comments')
|
||||
.select('*')
|
||||
.eq('order_id', orderId)
|
||||
.order('created_at', { ascending: true });
|
||||
setComments(data ?? []);
|
||||
}, [orderId]);
|
||||
|
||||
useEffect(() => { fetch(); }, [fetch]);
|
||||
|
||||
const addComment = async (author, body) => {
|
||||
setLoading(true);
|
||||
const { error } = await supabase.from('order_comments')
|
||||
.insert({ order_id: orderId, author, body });
|
||||
if (!error) await fetch();
|
||||
setLoading(false);
|
||||
return { error };
|
||||
};
|
||||
|
||||
return { comments, loading, addComment };
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE IF NOT EXISTS order_comments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
order_id UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
|
||||
author TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE order_comments ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY "auth_all" ON order_comments FOR ALL TO authenticated USING (true) WITH CHECK (true);
|
||||
Reference in New Issue
Block a user