mirror of
https://github.com/serty2005/rmser.git
synced 2026-02-04 19:02:33 -06:00
2701-есть флоу для оператора и красивый список накладных
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user