добавил архив фото, откуда можно удалить и посмотреть

This commit is contained in:
2026-01-19 05:17:15 +03:00
parent bc036197cf
commit 323fc67cd5
12 changed files with 648 additions and 26 deletions

View File

@@ -0,0 +1,215 @@
import React, { useState } from "react";
import {
Card,
Image,
Button,
Popconfirm,
Tag,
Pagination,
Empty,
Spin,
message,
Tooltip,
} from "antd";
import {
DeleteOutlined,
ReloadOutlined,
FileImageOutlined,
CheckCircleOutlined,
FileTextOutlined,
} from "@ant-design/icons";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api, getStaticUrl } from "../../services/api";
import { AxiosError } from "axios";
import type { ReceiptPhoto, PhotoStatus } from "../../services/types";
export const PhotoStorageTab: React.FC = () => {
const [page, setPage] = useState(1);
const queryClient = useQueryClient();
const { data, isLoading, isError } = useQuery({
queryKey: ["photos", page],
queryFn: () => api.getPhotos(page, 18), // 18 - удобно делится на 2, 3, 6 колонок
});
const deleteMutation = useMutation({
mutationFn: ({ id, force }: { id: string; force: boolean }) =>
api.deletePhoto(id, force),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["photos"] });
message.success("Фото удалено");
},
// Исправленная типизация:
onError: (error: AxiosError<{ error: string }>) => {
if (error.response?.status === 409) {
message.warning(
"Это фото связано с черновиком. Используйте кнопку 'Удалить' с подтверждением."
);
} else {
message.error(error.response?.data?.error || "Ошибка удаления");
}
},
});
const regenerateMutation = useMutation({
mutationFn: (id: string) => api.regenerateDraftFromPhoto(id),
onSuccess: () => {
message.success("Черновик восстановлен");
// Можно редиректить, но пока просто обновим список
},
onError: () => {
message.error("Ошибка восстановления");
},
});
const getStatusTag = (status: PhotoStatus) => {
switch (status) {
case "ORPHAN":
return <Tag color="default">Без привязки</Tag>;
case "HAS_DRAFT":
return (
<Tag icon={<FileTextOutlined />} color="processing">
Черновик
</Tag>
);
case "HAS_INVOICE":
return (
<Tag icon={<CheckCircleOutlined />} color="success">
В iiko
</Tag>
);
default:
return <Tag>{status}</Tag>;
}
};
if (isLoading) {
return (
<div style={{ textAlign: "center", padding: 40 }}>
<Spin size="large" />
</div>
);
}
if (isError) {
return <Empty description="Ошибка загрузки фото" />;
}
if (!data?.photos?.length) {
return <Empty description="Нет загруженных фото" />;
}
return (
<div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(160px, 1fr))",
gap: 16,
marginBottom: 24,
}}
>
{data.photos.map((photo: ReceiptPhoto) => (
<Card
key={photo.id}
hoverable
size="small"
cover={
<div
style={{
height: 160,
overflow: "hidden",
position: "relative",
}}
>
<Image
src={getStaticUrl(photo.file_url)}
alt={photo.file_name}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
preview={{ mask: <FileImageOutlined /> }}
/>
</div>
}
actions={[
photo.can_regenerate ? (
<Tooltip title="Создать черновик заново">
<Button
type="text"
icon={<ReloadOutlined />}
onClick={() => regenerateMutation.mutate(photo.id)}
loading={regenerateMutation.isPending}
size="small"
/>
</Tooltip>
) : (
<span />
), // Placeholder для выравнивания
photo.can_delete ? (
<Popconfirm
title="Удалить фото?"
description={
photo.status === "HAS_DRAFT"
? "Внимание! Черновик тоже будет удален."
: "Восстановить будет невозможно."
}
onConfirm={() =>
deleteMutation.mutate({
id: photo.id,
force: photo.status === "HAS_DRAFT",
})
}
okText="Удалить"
cancelText="Отмена"
okButtonProps={{ danger: true }}
>
<Button
type="text"
danger
icon={<DeleteOutlined />}
size="small"
/>
</Popconfirm>
) : (
<Tooltip title="Нельзя удалить: накладная уже в iiko">
<Button
type="text"
disabled
icon={<DeleteOutlined />}
size="small"
/>
</Tooltip>
),
]}
>
<Card.Meta
title={
<div
style={{
fontSize: 12,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{new Date(photo.created_at).toLocaleDateString()}
</div>
}
description={getStatusTag(photo.status)}
/>
</Card>
))}
</div>
<Pagination
current={page}
total={data.total}
pageSize={data.limit}
onChange={setPage}
showSizeChanger={false}
style={{ textAlign: "center" }}
simple
/>
</div>
);
};

View File

@@ -20,11 +20,13 @@ import {
SettingOutlined,
FolderOpenOutlined,
TeamOutlined,
CameraOutlined,
} from "@ant-design/icons";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../services/api";
import type { UserSettings } from "../services/types";
import { TeamList } from "../components/settings/TeamList";
import { PhotoStorageTab } from "../components/settings/PhotoStorageTab";
const { Title, Text } = Typography;
@@ -207,6 +209,16 @@ export const SettingsPage: React.FC = () => {
});
}
// Добавляем вкладку с фото (доступна для OWNER)
if (currentUserRole === "OWNER") {
tabsItems.push({
key: "photos",
label: "Архив фото",
icon: <CameraOutlined />,
children: <PhotoStorageTab />,
});
}
return (
<div style={{ padding: "0 16px 80px" }}>
<Title level={4} style={{ marginTop: 16 }}>

View File

@@ -25,7 +25,8 @@ import type {
UnifiedInvoice,
ServerUser,
UserRole,
InvoiceDetails
InvoiceDetails,
GetPhotosResponse
} from './types';
// Базовый URL
@@ -255,4 +256,21 @@ export const api = {
syncInvoices: async (): Promise<void> => {
await apiClient.post('/invoices/sync');
},
};
getPhotos: async (page = 1, limit = 20): Promise<GetPhotosResponse> => {
const { data } = await apiClient.get<GetPhotosResponse>('/photos', {
params: { page, limit }
});
return data;
},
deletePhoto: async (id: string, force = false): Promise<void> => {
await apiClient.delete(`/photos/${id}`, {
params: { force }
});
},
regenerateDraftFromPhoto: async (id: string): Promise<void> => {
await apiClient.post(`/photos/${id}/regenerate`);
},
};

View File

@@ -274,4 +274,28 @@ export interface InvoiceDetails {
total: number;
}[];
photo_url: string | null;
}
export type PhotoStatus = 'ORPHAN' | 'HAS_DRAFT' | 'HAS_INVOICE';
export interface ReceiptPhoto {
id: string;
rms_server_id: string;
uploaded_by: string;
file_url: string;
file_name: string;
file_size: number;
draft_id?: string;
invoice_id?: string;
created_at: string;
status: PhotoStatus;
can_delete: boolean;
can_regenerate: boolean;
}
export interface GetPhotosResponse {
photos: ReceiptPhoto[];
total: number;
page: number;
limit: number;
}