feat: photo attachments for orders via Supabase Storage
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, Save, FileText } from 'lucide-react';
|
||||
import { X, Save, FileText, Camera, X as XIcon } from 'lucide-react';
|
||||
import { useOrderPhotos } from './useOrderPhotos';
|
||||
import { pdf } from '@react-pdf/renderer';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { useAuth } from '../auth/useAuth';
|
||||
@@ -51,6 +52,7 @@ const OrderModal = ({ order, onClose, onSaved, mode = 'edit' }) => {
|
||||
address: order?.address || '',
|
||||
});
|
||||
const [events, setEvents] = useState([]);
|
||||
const { photos, uploading, uploadPhotos, deletePhoto } = useOrderPhotos(mode === 'edit' ? order?.id : null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
@@ -277,6 +279,60 @@ const OrderModal = ({ order, onClose, onSaved, mode = 'edit' }) => {
|
||||
<textarea id="modal-notes" name="notes" value={form.notes} onChange={set} style={{ ...inputStyle, height: '72px', resize: 'vertical' }} />
|
||||
</div>
|
||||
|
||||
{mode === 'edit' && (
|
||||
<div style={{ marginTop: '14px' }}>
|
||||
<label style={labelStyle}>Фотографии объекта</label>
|
||||
|
||||
{/* Thumbnail grid */}
|
||||
{photos.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginBottom: '10px' }}>
|
||||
{photos.map((photo) => (
|
||||
<div key={photo.name} style={{ position: 'relative', width: '80px', height: '80px' }}>
|
||||
<img
|
||||
src={photo.url}
|
||||
alt={photo.name}
|
||||
style={{ width: '80px', height: '80px', objectFit: 'cover', borderRadius: '8px', border: '1px solid var(--border-light)' }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => deletePhoto(photo.name)}
|
||||
style={{
|
||||
position: 'absolute', top: '-6px', right: '-6px',
|
||||
background: '#ef4444', color: '#fff', border: 'none',
|
||||
borderRadius: '50%', width: '20px', height: '20px',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', padding: 0,
|
||||
}}
|
||||
>
|
||||
<XIcon size={11} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload button */}
|
||||
<label style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: '7px',
|
||||
padding: '8px 14px',
|
||||
border: '1.5px dashed var(--border-light)',
|
||||
borderRadius: '8px', cursor: uploading ? 'not-allowed' : 'pointer',
|
||||
fontSize: '0.85rem', color: 'var(--text-muted)',
|
||||
opacity: uploading ? 0.6 : 1,
|
||||
}}>
|
||||
<Camera size={15} />
|
||||
{uploading ? 'Загрузка...' : 'Добавить фото'}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
disabled={uploading}
|
||||
onChange={(e) => uploadPhotos(e.target.files)}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p role="alert" style={{ color: '#ef4444', fontSize: '0.85rem', marginTop: '10px' }}>{error}</p>}
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '20px' }}>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
|
||||
const BUCKET = 'order-photos';
|
||||
|
||||
export function useOrderPhotos(orderId) {
|
||||
const [photos, setPhotos] = useState([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const fetchPhotos = useCallback(async () => {
|
||||
if (!orderId) return;
|
||||
const { data, error: err } = await supabase.storage.from(BUCKET).list(`${orderId}/`);
|
||||
if (err) { setError(err.message); return; }
|
||||
const urls = (data || [])
|
||||
.filter((f) => f.name !== '.emptyFolderPlaceholder')
|
||||
.map((f) => ({
|
||||
name: f.name,
|
||||
url: supabase.storage.from(BUCKET).getPublicUrl(`${orderId}/${f.name}`).data.publicUrl,
|
||||
}));
|
||||
setPhotos(urls);
|
||||
}, [orderId]);
|
||||
|
||||
useEffect(() => { fetchPhotos(); }, [fetchPhotos]);
|
||||
|
||||
const uploadPhotos = async (files) => {
|
||||
if (!orderId || !files.length) return;
|
||||
setUploading(true);
|
||||
setError(null);
|
||||
for (const file of Array.from(files)) {
|
||||
const ext = file.name.split('.').pop();
|
||||
const path = `${orderId}/${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`;
|
||||
const { error: upErr } = await supabase.storage.from(BUCKET).upload(path, file, { upsert: false });
|
||||
if (upErr) { setError(upErr.message); }
|
||||
}
|
||||
setUploading(false);
|
||||
await fetchPhotos();
|
||||
};
|
||||
|
||||
const deletePhoto = async (name) => {
|
||||
const { error: delErr } = await supabase.storage.from(BUCKET).remove([`${orderId}/${name}`]);
|
||||
if (delErr) { setError(delErr.message); return; }
|
||||
setPhotos((prev) => prev.filter((p) => p.name !== name));
|
||||
};
|
||||
|
||||
return { photos, uploading, error, uploadPhotos, deletePhoto };
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Create storage bucket for order photos
|
||||
-- Run in Supabase SQL Editor
|
||||
INSERT INTO storage.buckets (id, name, public)
|
||||
VALUES ('order-photos', 'order-photos', true)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Allow authenticated users to upload
|
||||
CREATE POLICY "Authenticated users can upload order photos"
|
||||
ON storage.objects FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (bucket_id = 'order-photos');
|
||||
|
||||
-- Allow authenticated users to delete own uploads
|
||||
CREATE POLICY "Authenticated users can delete order photos"
|
||||
ON storage.objects FOR DELETE
|
||||
TO authenticated
|
||||
USING (bucket_id = 'order-photos');
|
||||
|
||||
-- Public read
|
||||
CREATE POLICY "Public read order photos"
|
||||
ON storage.objects FOR SELECT
|
||||
USING (bucket_id = 'order-photos');
|
||||
Reference in New Issue
Block a user