2701-есть флоу для оператора и красивый список накладных

This commit is contained in:
2026-01-27 06:31:38 +03:00
parent 8332b6ecda
commit 38a5143902
11 changed files with 1508 additions and 158 deletions

View File

@@ -1,6 +1,6 @@
// src/pages/DraftsList.tsx
import React, { useState } from "react";
import React, { useState, useMemo, useCallback, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import {
List,
@@ -8,13 +8,13 @@ import {
Tag,
Spin,
Empty,
DatePicker,
Flex,
Button,
Select,
DatePicker,
} from "antd";
import { useNavigate } from "react-router-dom";
import {
ArrowRightOutlined,
CheckCircleOutlined,
DeleteOutlined,
PlusOutlined,
@@ -25,21 +25,58 @@ import {
SyncOutlined,
CloudServerOutlined,
} from "@ant-design/icons";
import dayjs, { Dayjs } from "dayjs";
import dayjs from "dayjs";
import "dayjs/locale/ru";
import { api } from "../services/api";
import type { UnifiedInvoice } from "../services/types";
const { Title, Text } = Typography;
type FilterType = "ALL" | "DRAFT" | "SYNCED";
dayjs.locale("ru");
const DayDivider: React.FC<{ date: string }> = ({ date }) => {
const d = dayjs(date);
const dayOfWeek = d.format("dddd");
const formattedDate = d.format("D MMMM YYYY");
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: 12,
padding: "16px 0 8px",
borderBottom: "1px solid #f0f0f0",
marginBottom: 8,
}}
>
<Text strong style={{ fontSize: 14, color: "#1890ff" }}>
{formattedDate}
</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
{dayOfWeek}
</Text>
</div>
);
};
export const DraftsList: React.FC = () => {
const navigate = useNavigate();
// Состояние фильтра дат: по умолчанию последние 7 дней
const [startDate, setStartDate] = useState<Dayjs>(dayjs().subtract(7, "day"));
const [endDate, setEndDate] = useState<Dayjs>(dayjs());
const [syncLoading, setSyncLoading] = useState(false);
const [filterType, setFilterType] = useState<FilterType>("ALL");
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [startDate, setStartDate] = useState<dayjs.Dayjs | null>(
dayjs().subtract(30, "day")
);
const [endDate, setEndDate] = useState<dayjs.Dayjs | null>(dayjs());
const touchStartX = useRef<number>(0);
const touchEndX = useRef<number>(0);
// Запрос данных с учетом дат (даты в ключе обеспечивают авто-перезапрос)
const {
data: invoices,
isLoading,
@@ -48,13 +85,13 @@ export const DraftsList: React.FC = () => {
} = useQuery({
queryKey: [
"drafts",
startDate.format("YYYY-MM-DD"),
endDate.format("YYYY-MM-DD"),
startDate?.format("YYYY-MM-DD"),
endDate?.format("YYYY-MM-DD"),
],
queryFn: () =>
api.getDrafts(
startDate.format("YYYY-MM-DD"),
endDate.format("YYYY-MM-DD")
startDate?.format("YYYY-MM-DD"),
endDate?.format("YYYY-MM-DD")
),
staleTime: 0,
refetchOnMount: true,
@@ -134,6 +171,106 @@ export const DraftsList: React.FC = () => {
}
};
const handleFilterChange = (value: FilterType) => {
setFilterType(value);
setCurrentPage(1);
};
const handlePageSizeChange = (value: number) => {
setPageSize(value);
setCurrentPage(1);
};
const handleTouchStart = useCallback((e: React.TouchEvent) => {
touchStartX.current = e.changedTouches[0].screenX;
}, []);
const handleTouchMove = useCallback((e: React.TouchEvent) => {
touchEndX.current = e.changedTouches[0].screenX;
}, []);
const getItemDate = (item: UnifiedInvoice) =>
item.type === "DRAFT" ? item.created_at : item.date_incoming;
const filteredAndSortedInvoices = useMemo(() => {
if (!invoices || invoices.length === 0) return [];
let result = [...invoices];
if (filterType !== "ALL") {
result = result.filter((item) => item.type === filterType);
}
result.sort((a, b) => {
const dateA = dayjs(getItemDate(a)).startOf("day");
const dateB = dayjs(getItemDate(b)).startOf("day");
// Сначала по дате DESC
if (!dateA.isSame(dateB)) {
return dateB.valueOf() - dateA.valueOf();
}
// Внутри дня: DRAFT < SYNCED
if (a.type !== b.type) {
return a.type === "DRAFT" ? -1 : 1;
}
// Внутри типа: по номеру DESC
return (b.document_number || "").localeCompare(
a.document_number || "",
"ru",
{ numeric: true }
);
});
return result;
}, [invoices, filterType]);
const handleTouchEnd = useCallback(() => {
const diff = touchStartX.current - touchEndX.current;
const totalPages = Math.ceil(
(filteredAndSortedInvoices.length || 0) / pageSize
);
if (Math.abs(diff) > 50) {
if (diff > 0 && currentPage < totalPages) {
setCurrentPage((prev) => prev + 1);
} else if (diff < 0 && currentPage > 1) {
setCurrentPage((prev) => prev - 1);
}
}
}, [currentPage, pageSize, filteredAndSortedInvoices]);
const paginatedInvoices = useMemo(() => {
const startIndex = (currentPage - 1) * pageSize;
return filteredAndSortedInvoices.slice(startIndex, startIndex + pageSize);
}, [filteredAndSortedInvoices, currentPage, pageSize]);
const groupedInvoices = useMemo(() => {
const groups: { [key: string]: UnifiedInvoice[] } = {};
paginatedInvoices.forEach((item) => {
const dateKey = dayjs(getItemDate(item)).format("YYYY-MM-DD");
if (!groups[dateKey]) {
groups[dateKey] = [];
}
groups[dateKey].push(item);
});
return groups;
}, [paginatedInvoices]);
const filterCounts = useMemo(() => {
if (!invoices) return { all: 0, draft: 0, synced: 0 };
return {
all: invoices.length,
draft: invoices.filter((item) => item.type === "DRAFT").length,
synced: invoices.filter((item) => item.type === "SYNCED").length,
};
}, [invoices]);
const totalPages = Math.ceil(
(filteredAndSortedInvoices.length || 0) / pageSize
);
if (isError) {
return (
<div style={{ padding: 20 }}>
@@ -144,48 +281,60 @@ export const DraftsList: React.FC = () => {
return (
<div style={{ padding: "0 4px 20px" }}>
<Flex align="center" gap={8} style={{ marginTop: 16, marginBottom: 16 }}>
<Title level={4} style={{ margin: 0 }}>
Накладные
</Title>
<Button
icon={<SyncOutlined />}
loading={syncLoading}
onClick={handleSync}
<Flex
align="center"
justify="space-between"
style={{ marginTop: 16, marginBottom: 16 }}
>
<Flex align="center" gap={8}>
<Title level={4} style={{ margin: 0 }}>
Накладные
</Title>
<Button
icon={<SyncOutlined />}
loading={syncLoading}
onClick={handleSync}
/>
</Flex>
<Select
value={filterType}
onChange={handleFilterChange}
options={[
{ label: `Все (${filterCounts.all})`, value: "ALL" },
{ label: `Черновики (${filterCounts.draft})`, value: "DRAFT" },
{ label: `Накладные (${filterCounts.synced})`, value: "SYNCED" },
]}
size="small"
style={{ width: 140 }}
/>
</Flex>
{/* Фильтр дат */}
<div
style={{
marginBottom: 16,
marginBottom: 12,
background: "#fff",
padding: 12,
borderRadius: 8,
}}
>
<Text
type="secondary"
style={{ display: "block", marginBottom: 8, fontSize: 12 }}
>
Период загрузки:
</Text>
<Flex gap={8}>
<Flex align="center" gap={8}>
<Text style={{ fontSize: 13 }}>Период:</Text>
<DatePicker
value={startDate}
onChange={(date) => date && setStartDate(date)}
style={{ flex: 1 }}
placeholder="Начало"
onChange={setStartDate}
format="DD.MM.YYYY"
allowClear={false}
size="small"
placeholder="Начало"
style={{ width: 110 }}
/>
<Text type="secondary"></Text>
<DatePicker
value={endDate}
onChange={(date) => date && setEndDate(date)}
style={{ flex: 1 }}
placeholder="Конец"
onChange={setEndDate}
format="DD.MM.YYYY"
allowClear={false}
size="small"
placeholder="Конец"
style={{ width: 110 }}
/>
</Flex>
</div>
@@ -195,119 +344,194 @@ export const DraftsList: React.FC = () => {
<Spin size="large" />
</div>
) : !invoices || invoices.length === 0 ? (
<Empty description="Нет данных за выбранный период" />
<Empty description="Нет данных" />
) : (
<List
dataSource={invoices}
renderItem={(item) => {
const isSynced = item.type === "SYNCED";
const displayDate =
item.type === "DRAFT" ? item.created_at : item.date_incoming;
<>
<div
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
{Object.entries(groupedInvoices).map(([dateKey, items]) => (
<div key={dateKey}>
<DayDivider date={dateKey} />
{items.map((item) => {
const isSynced = item.type === "SYNCED";
const displayDate =
item.type === "DRAFT"
? item.created_at
: item.date_incoming;
return (
<List.Item
style={{
background: isSynced ? "#fafafa" : "#fff",
padding: 12,
marginBottom: 10,
borderRadius: 12,
cursor: "pointer",
border: isSynced ? "1px solid #f0f0f0" : "1px solid #e6f7ff",
boxShadow: "0 2px 4px rgba(0,0,0,0.02)",
display: "block",
}}
onClick={() => handleInvoiceClick(item)}
>
<Flex vertical gap={4}>
<Flex justify="space-between" align="start">
<Flex vertical>
<Flex align="center" gap={8}>
<Text strong style={{ fontSize: 16 }}>
{item.document_number || "Без номера"}
</Text>
{item.type === "SYNCED" && (
<CloudServerOutlined style={{ color: "gray" }} />
)}
{item.is_app_created && (
<span title="Создано в RMSer">📱</span>
)}
</Flex>
<Flex vertical gap={2}>
<Text type="secondary" style={{ fontSize: 13 }}>
{item.items_count} поз.
</Text>
<Text type="secondary" style={{ fontSize: 13 }}>
{dayjs(displayDate).format("DD.MM.YYYY")}
</Text>
</Flex>
{item.incoming_number && (
<Text type="secondary" style={{ fontSize: 12 }}>
Вх. {item.incoming_number}
</Text>
)}
</Flex>
<Flex vertical align="start" gap={4}>
{getStatusTag(item)}
{isSynced && item.items_preview && (
<div>
{item.items_preview
.split(", ")
.map((previewItem, idx) => (
<div
key={idx}
style={{ fontSize: 12, color: "#666" }}
>
{previewItem}
</div>
))}
</div>
)}
</Flex>
</Flex>
<Flex justify="space-between" align="center">
<div></div>
{item.store_name && (
<Tag
return (
<List.Item
key={item.id}
style={{
background: isSynced ? "#fafafa" : "#fff",
padding: 12,
marginBottom: 10,
borderRadius: 12,
cursor: "pointer",
border: isSynced
? "1px solid #f0f0f0"
: "1px solid #e6f7ff",
boxShadow: "0 2px 4px rgba(0,0,0,0.02)",
display: "block",
position: "relative",
}}
onClick={() => handleInvoiceClick(item)}
>
<div
style={{
margin: 0,
maxWidth: 120,
overflow: "hidden",
textOverflow: "ellipsis",
position: "absolute",
top: 12,
right: 12,
}}
>
{item.store_name}
</Tag>
)}
</Flex>
{getStatusTag(item)}
</div>
<Flex
justify="space-between"
align="center"
style={{ marginTop: 8 }}
>
<Text
strong
style={{
fontSize: 17,
color: isSynced ? "#595959" : "#1890ff",
}}
>
{item.total_sum.toLocaleString("ru-RU", {
style: "currency",
currency: "RUB",
maximumFractionDigits: 0,
})}
</Text>
{!isSynced && (
<ArrowRightOutlined style={{ color: "#1890ff" }} />
)}
</Flex>
</Flex>
</List.Item>
);
}}
/>
<Flex vertical gap={4}>
<Flex align="center" gap={8}>
<Text strong style={{ fontSize: 16 }}>
{item.document_number || "Без номера"}
</Text>
{item.type === "SYNCED" && (
<CloudServerOutlined style={{ color: "gray" }} />
)}
{item.is_app_created && (
<span title="Создано в RMSer">📱</span>
)}
</Flex>
<Flex vertical gap={2}>
<Text type="secondary" style={{ fontSize: 13 }}>
{item.items_count} поз.
</Text>
<Text type="secondary" style={{ fontSize: 13 }}>
{dayjs(displayDate).format("DD.MM.YYYY")}
</Text>
</Flex>
{item.incoming_number && (
<Text type="secondary" style={{ fontSize: 12 }}>
Вх. {item.incoming_number}
</Text>
)}
<Flex justify="space-between" align="center">
<div></div>
{item.store_name && (
<Tag
style={{
margin: 0,
maxWidth: 120,
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{item.store_name}
</Tag>
)}
</Flex>
<Flex
justify="space-between"
align="center"
style={{ marginTop: 8 }}
>
<Text
strong
style={{
fontSize: 17,
color: isSynced ? "#595959" : "#1890ff",
}}
>
{item.total_sum.toLocaleString("ru-RU", {
style: "currency",
currency: "RUB",
maximumFractionDigits: 0,
})}
</Text>
{item.items_preview && (
<div
style={{
textAlign: "right",
maxWidth: 150,
}}
>
{item.items_preview
.split(", ")
.slice(0, 3)
.map((previewItem, idx) => (
<div
key={idx}
style={{
fontSize: 12,
color: "#666",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{previewItem}
</div>
))}
</div>
)}
</Flex>
</Flex>
</List.Item>
);
})}
</div>
))}
</div>
{totalPages > 1 && (
<Flex
justify="space-between"
align="center"
style={{
marginTop: 16,
padding: "8px 12px",
background: "#fff",
borderRadius: 8,
}}
>
<Flex align="center" gap={8}>
<Text style={{ fontSize: 12 }}>На странице:</Text>
<Select
value={pageSize}
onChange={handlePageSizeChange}
options={[
{ label: "10", value: 10 },
{ label: "20", value: 20 },
{ label: "50", value: 50 },
]}
size="small"
style={{ width: 70 }}
/>
</Flex>
<Flex align="center" gap={8}>
<Text style={{ fontSize: 13 }}>
Стр. {currentPage} из {totalPages}
</Text>
<Button
size="small"
disabled={currentPage === 1}
onClick={() => setCurrentPage((prev) => prev - 1)}
>
</Button>
<Button
size="small"
disabled={currentPage === totalPages}
onClick={() => setCurrentPage((prev) => prev + 1)}
>
</Button>
</Flex>
</Flex>
)}
</>
)}
</div>
);