feat(core): sgw-device-ui

This commit is contained in:
Tran Anh Tuan
2025-09-26 18:22:04 +07:00
parent 466e931537
commit 2707b92f7e
88 changed files with 19104 additions and 0 deletions

View File

@@ -0,0 +1,103 @@
import {
DATE_TIME_FORMAT,
DURATION_POLLING_PRESENTATIONS,
STATUS_DANGEROUS,
STATUS_NORMAL,
STATUS_SOS,
STATUS_WARNING,
} from '@/utils/format';
import { ProList, ProListMetas } from '@ant-design/pro-components';
import { Badge, theme, Typography } from 'antd';
import moment from 'moment';
import { useRef } from 'react';
const getBadgeLevel = (level: number) => {
switch (level) {
case STATUS_NORMAL:
return <Badge status="success" />;
case STATUS_WARNING:
return <Badge status="warning" />;
case STATUS_DANGEROUS:
return <Badge status="error" />;
case STATUS_SOS:
return <Badge status="error" />;
default:
return <Badge status="default" />;
}
};
interface AlarmTableProps {
alarmList?: API.Alarm[];
isLoading?: boolean;
}
export const AlarmTable: React.FC<AlarmTableProps> = ({
alarmList,
isLoading,
}) => {
// const intl = useIntl();
const actionRef = useRef();
const { token } = theme.useToken();
// const [messageApi, contextHolder] = message.useMessage();
// const [confirmModalVisible, handleConfirmModalVisible] = useState(false);
// const [currentRow, setCurrentRow] = useState({});
const getTitleAlarmColor = (level: number) => {
switch (level) {
case STATUS_NORMAL:
return token.colorSuccess;
case STATUS_WARNING:
return token.colorWarning;
case STATUS_DANGEROUS:
return token.colorError;
case STATUS_SOS:
return token.colorError;
default:
return token.colorText;
}
};
const columns: ProListMetas<API.Alarm> = {
title: {
dataIndex: 'name',
render(_, item) {
return (
<Typography.Text style={{ color: getTitleAlarmColor(item.level) }}>
{item.name}
</Typography.Text>
);
},
},
avatar: {
render: (_, item) => getBadgeLevel(item.level),
},
description: {
dataIndex: 'time',
render: (_, item) => {
return (
<>
<div>{moment.unix(item?.t).format(DATE_TIME_FORMAT)}</div>
</>
);
},
},
};
return (
<>
<ProList<API.Alarm>
// bordered
actionRef={actionRef}
metas={columns}
polling={DURATION_POLLING_PRESENTATIONS}
loading={isLoading}
dataSource={alarmList}
search={false}
dateFormatter="string"
cardProps={{
bodyStyle: { paddingInline: 16, paddingBlock: 8 },
}}
/>
</>
);
};

View File

@@ -0,0 +1,46 @@
import type { BadgeProps } from 'antd';
import { Badge } from 'antd';
import React from 'react';
interface BadgeTripStatusProps {
status?: number;
}
const BadgeTripStatus: React.FC<BadgeTripStatusProps> = ({ status }) => {
// Khai báo kiểu cho map
const statusBadgeMap: Record<number, BadgeProps> = {
0: { status: 'default' }, // Đã khởi tạo
1: { status: 'processing' }, // Chờ duyệt
2: { status: 'success' }, // Đã duyệt
3: { status: 'success' }, // Đang hoạt động
4: { status: 'success' }, // Hoàn thành
5: { status: 'error' }, // Đã huỷ
};
const getBadgeProps = (status: number | undefined) => {
switch (status) {
case 0:
return 'Chưa được phê duyệt';
case 1:
return 'Đang chờ duyệt';
case 2:
return 'Đã duyệt';
case 3:
return 'Đang hoạt động';
case 4:
return 'Đã hoàn thành';
case 5:
return 'Đã huỷ';
default:
return 'Trạng thái không xác định';
}
};
const badgeProps: BadgeProps = statusBadgeMap[status ?? -1] || {
status: 'default',
};
return <Badge {...badgeProps} text={getBadgeProps(status)} />;
};
export default BadgeTripStatus;

View File

@@ -0,0 +1,38 @@
import { ModalForm, ProFormTextArea } from '@ant-design/pro-components';
import { Button, Form } from 'antd';
interface CancelTripProps {
onFinished?: (note: string) => void;
}
const CancelTrip: React.FC<CancelTripProps> = ({ onFinished }) => {
const [form] = Form.useForm<{ note: string }>();
return (
<ModalForm
title="Xác nhận huỷ chuyến đi"
form={form}
modalProps={{
destroyOnHidden: true,
onCancel: () => console.log('run'),
}}
trigger={
<Button color="danger" variant="solid">
Huỷ chuyến đi
</Button>
}
onFinish={async (values) => {
onFinished?.(values.note);
return true;
}}
>
<ProFormTextArea
name="note"
label="Lý do: "
placeholder={'Nhập lý do huỷ chuyến đi...'}
rules={[
{ required: true, message: 'Vui lòng nhập lý do huỷ chuyến đi' },
]}
/>
</ModalForm>
);
};
export default CancelTrip;

View File

@@ -0,0 +1,133 @@
import { STATUS } from '@/constants/enums';
import { getGPS } from '@/services/controller/DeviceController';
import {
startNewHaul,
updateTripState,
} from '@/services/controller/TripController';
import { PlusOutlined } from '@ant-design/icons';
import { useModel } from '@umijs/max';
import { Button, message, theme } from 'antd';
import { useState } from 'react';
import CreateOrUpdateFishingLog from './CreateOrUpdateFishingLog';
interface CreateNewHaulOrTripProps {
trips?: API.Trip;
onCallBack?: (success: string) => void;
}
const CreateNewHaulOrTrip: React.FC<CreateNewHaulOrTripProps> = ({
trips,
onCallBack,
}) => {
const [isFinishHaulModalOpen, setIsFinishHaulModalOpen] = useState(false);
const { token } = theme.useToken();
const { getApi } = useModel('getTrip');
const checkHaulFinished = () => {
return trips?.fishing_logs?.some((h) => h.status === 0);
};
const createNewHaul = async () => {
if (trips?.fishing_logs?.some((f) => f.status === 0)) {
message.warning(
'Vui lòng kết thúc mẻ lưới hiện tại trước khi bắt đầu mẻ mới',
);
return;
}
try {
const gpsData = await getGPS();
console.log('GPS Data:', gpsData);
const body: API.NewFishingLogRequest = {
trip_id: trips?.id || '',
start_at: new Date(),
start_lat: gpsData.lat,
start_lon: gpsData.lon,
weather_description: 'Nắng đẹp',
};
const resp = await startNewHaul(body);
onCallBack?.(STATUS.CREATE_FISHING_LOG_SUCCESS);
getApi();
} catch (error) {
console.log(error);
onCallBack?.(STATUS.CREATE_FISHING_LOG_FAIL);
}
};
const handleStartTrip = async (state: number, note?: string) => {
if (trips?.trip_status !== 2) {
message.warning('Chuyến đi đã được bắt đầu hoặc hoàn thành.');
return;
}
try {
const resp = await updateTripState({ status: state, note: note || '' });
onCallBack?.(STATUS.START_TRIP_SUCCESS);
getApi();
} catch (error) {
console.error('Error stating trip :', error);
onCallBack?.(STATUS.START_TRIP_FAIL);
}
};
// Không render gì nếu trip đã hoàn thành hoặc bị hủy
if (trips?.trip_status === 4 || trips?.trip_status === 5) {
return null;
}
return (
<>
{trips?.trip_status === 2 ? (
<Button
color="green"
variant="solid"
onClick={async () => handleStartTrip(3)}
>
Bắt đu chuyến đi
</Button>
) : checkHaulFinished() ? (
<Button
key="button"
icon={<PlusOutlined />}
onClick={async () => {
setIsFinishHaulModalOpen(true);
}}
color="geekblue"
variant="solid"
>
Kết thúc mẻ lưới
</Button>
) : (
<Button
key="button"
icon={<PlusOutlined />}
onClick={async () => {
createNewHaul();
}}
color="cyan"
variant="solid"
>
Bắt đu mẻ lưới
</Button>
)}
<CreateOrUpdateFishingLog
trip={trips!}
isFinished={false}
fishingLogs={undefined}
isOpen={isFinishHaulModalOpen}
onOpenChange={setIsFinishHaulModalOpen}
onFinished={(success) => {
if (success) {
onCallBack?.(STATUS.UPDATE_FISHING_LOG_SUCCESS);
getApi();
} else {
onCallBack?.(STATUS.UPDATE_FISHING_LOG_FAIL);
}
}}
/>
</>
);
};
export default CreateNewHaulOrTrip;

View File

@@ -0,0 +1,359 @@
import { getGPS } from '@/services/controller/DeviceController';
import {
getFishSpecies,
updateFishingLogs,
} from '@/services/controller/TripController';
import { getColorByRarityLevel, getRarityById } from '@/utils/fishRarity';
import { DeleteOutlined } from '@ant-design/icons';
import { EditableProTable, ProColumns } from '@ant-design/pro-components';
import { Button, Flex, message, Modal, Tag, Tooltip, Typography } from 'antd';
import React, { useEffect, useState } from 'react';
interface CreateOrUpdateFishingLogProps {
trip: API.Trip;
fishingLogs?: API.FishingLog;
isFinished: boolean;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
onFinished?: (success: boolean) => void;
}
interface FishingLogInfoWithKey extends API.FishingLogInfo {
key: React.Key;
}
const CreateOrUpdateFishingLog: React.FC<CreateOrUpdateFishingLogProps> = ({
trip,
fishingLogs,
isFinished,
isOpen,
onOpenChange,
onFinished,
}) => {
const [dataSource, setDataSource] = useState<
readonly FishingLogInfoWithKey[]
>([]);
const [editableKeys, setEditableRowKeys] = useState<React.Key[]>([]);
const [fishDatas, setFishDatas] = useState<API.FishSpeciesResponse[]>([]);
useEffect(() => {
getAllFish();
if (isOpen) {
console.log('Modal opened with fishingLogs:', fishingLogs);
if (fishingLogs?.info && fishingLogs.info.length > 0) {
const dataWithKeys: FishingLogInfoWithKey[] = fishingLogs.info.map(
(item, index) => ({
...item,
key: index,
}),
);
setDataSource(dataWithKeys);
setEditableRowKeys(dataWithKeys.map((item) => item.key));
} else {
// Nếu không có info thì reset table
setDataSource([]);
setEditableRowKeys([]);
}
}
}, [isOpen, fishingLogs]);
const getAllFish = async () => {
try {
const resp = await getFishSpecies();
setFishDatas(resp);
console.log('Fetched fish species:', resp);
} catch (error) {
console.error('Error fetching fish species:', error);
}
};
const columns: ProColumns<FishingLogInfoWithKey>[] = [
{
title: 'Tên cá',
dataIndex: 'fish_species_id',
valueType: 'select',
fieldProps: {
showSearch: true,
options: fishDatas.map((f) => ({
label: f.name,
value: f.id,
data: JSON.stringify(f),
})),
optionRender: (option: any) => {
const fish: API.FishSpeciesResponse = JSON.parse(option.data.data);
const fishRarity = getRarityById(fish.rarity_level || 1);
return (
<Flex align="center" gap={8}>
<Typography.Text>{fish.name}</Typography.Text>
<Tooltip title={fishRarity?.rarityDescription || ''}>
<Tag color={getColorByRarityLevel(fish.rarity_level || 1)}>
{fishRarity?.rarityLabel}
</Tag>
</Tooltip>
</Flex>
);
},
},
formItemProps: {
rules: [{ required: true, message: 'Vui lòng chọn tên cá' }],
},
},
{
title: 'Kích thước (cm)',
dataIndex: 'fish_size',
valueType: 'digit',
width: '15%',
formItemProps: {
rules: [
{
required: true,
message: 'Vui lòng nhập kích thước',
},
],
},
},
{
title: 'Số lượng',
dataIndex: 'catch_number',
valueType: 'digit',
width: '15%',
formItemProps: {
rules: [
{
required: true,
message: 'Vui lòng nhập số lượng',
},
],
},
},
{
title: 'Đơn vị',
dataIndex: 'catch_unit',
valueType: 'select',
width: '15%',
valueEnum: {
kg: 'kg',
con: 'con',
tấn: 'tấn',
},
formItemProps: {
rules: [
{
required: true,
message: 'Vui lòng chọn đơn vị',
},
],
},
},
{
title: 'Thao tác',
valueType: 'option',
width: 120,
render: () => {
return null;
},
},
];
async function createOrCreateOrUpdateFishingLog(
fishingLog: FishingLogInfoWithKey[],
) {
console.log('Is finished:', isFinished);
console.log('Trip:', trip);
try {
const gpsData = await getGPS();
if (gpsData) {
if (isFinished == false) {
// Tạo mẻ mới
const logStatus0 = trip.fishing_logs?.find((log) => log.status === 0);
console.log('ok', logStatus0);
console.log('ok', fishingLog);
const body: API.FishingLog = {
fishing_log_id: logStatus0?.fishing_log_id || '',
trip_id: trip.id,
start_at: logStatus0?.start_at!,
start_lat: logStatus0?.start_lat!,
start_lon: logStatus0?.start_lon!,
haul_lat: gpsData.lat,
haul_lon: gpsData.lon,
end_at: new Date(),
status: 1,
weather_description: logStatus0?.weather_description || '',
info: fishingLog.map((item) => ({
fish_species_id: item.fish_species_id,
fish_name: item.fish_name,
catch_number: item.catch_number,
catch_unit: item.catch_unit,
fish_size: item.fish_size,
fish_rarity: item.fish_rarity,
fish_condition: '',
gear_usage: '',
})),
sync: true,
};
const resp = await updateFishingLogs(body);
console.log('Resp', resp);
onFinished?.(true);
onOpenChange(false);
} else {
const body: API.FishingLog = {
fishing_log_id: fishingLogs?.fishing_log_id || '',
trip_id: fishingLogs?.trip_id!,
start_at: fishingLogs?.start_at!,
start_lat: fishingLogs?.start_lat!,
start_lon: fishingLogs?.start_lon!,
haul_lat: fishingLogs?.haul_lat!,
haul_lon: fishingLogs?.haul_lon!,
end_at: fishingLogs?.end_at!,
status: fishingLogs?.status!,
weather_description: fishingLogs?.weather_description || '',
info: fishingLog.map((item) => ({
fish_species_id: item.fish_species_id,
fish_name: item.fish_name,
catch_number: item.catch_number,
catch_unit: item.catch_unit,
fish_size: item.fish_size,
fish_rarity: item.fish_rarity,
fish_condition: '',
gear_usage: '',
})),
sync: true,
};
// console.log('Update body:', body);
const resp = await updateFishingLogs(body);
console.log('Resp', resp);
onFinished?.(true);
onOpenChange(false);
}
setDataSource([]);
setEditableRowKeys([]);
} else {
message.error('Không thể lấy dữ liệu GPS. Vui lòng thử lại.');
}
} catch (error) {
onFinished?.(false);
console.error('Error creating/updating haul:', error);
}
}
return (
<Modal
width="70%"
title={isFinished ? 'Cập nhật mẻ lưới' : 'Kết thúc mẻ lưới'}
open={isOpen}
cancelText="Huỷ"
maskClosable={false}
okText={isFinished ? 'Cập nhật' : 'Kết thúc'}
onCancel={() => onOpenChange(false)}
onOk={async () => {
// Validate data trước khi submit
const validData = dataSource.filter(
(item) =>
item.fish_name &&
item.fish_size &&
item.catch_number &&
item.catch_unit,
);
if (validData.length === 0) {
message.error(
'Vui lòng nhập ít nhất một loài cá với đầy đủ thông tin',
);
return;
}
await createOrCreateOrUpdateFishingLog(validData);
}}
>
<EditableProTable<FishingLogInfoWithKey>
key={fishingLogs?.fishing_log_id}
headerTitle="Danh sách cá đánh bắt"
columns={columns}
rowKey="key"
scroll={{
x: 960,
}}
value={dataSource}
onChange={setDataSource}
recordCreatorProps={{
newRecordType: 'dataSource',
creatorButtonText: 'Thêm loài',
record: () => ({
key: Date.now(),
}),
}}
editable={{
type: 'multiple',
editableKeys,
actionRender: (row, config, defaultDoms) => {
return [defaultDoms.delete];
},
deletePopconfirmMessage: 'Bạn chắc chắn muốn xoá?',
onValuesChange: (
record: Partial<FishingLogInfoWithKey> | undefined,
recordList: FishingLogInfoWithKey[],
) => {
// Nếu không có record (sự kiện không liên quan tới 1 dòng cụ thể) thì chỉ cập nhật dataSource
if (!record) {
setDataSource([...recordList]);
return;
}
// Lấy giá trị species id (cẩn trọng string/number)
const speciesId = (record as any).fish_species_id;
if (speciesId === undefined || speciesId === null) {
// Nếu không phải là thay đổi chọn loài cá, chỉ cập nhật dataSource
setDataSource([...recordList]);
return;
}
// Tìm loài cá tương ứng, so sánh bằng String để tránh khác kiểu number/string
const fish = fishDatas.find(
(f) => String(f.id) === String(speciesId),
);
if (!fish) {
setDataSource([...recordList]);
return;
}
// Tạo record mới (merge thông tin loài cá vào dòng hiện tại)
const mergedRecord: FishingLogInfoWithKey = {
...(record as FishingLogInfoWithKey),
fish_species_id: fish.id,
fish_name: fish.name,
catch_unit: fish.default_unit,
fish_rarity: fish.rarity_level,
};
// Áp lại vào recordList dựa theo key (so khớp key bằng String để an toàn)
const newList = recordList.map((r) =>
String(r.key) === String(mergedRecord.key) ? mergedRecord : r,
);
// Cập nhật state (sao chép mảng để tránh vấn đề readonly/type)
setDataSource([...newList]);
// Đảm bảo dòng này đang ở trạng thái editable (nếu cần)
setEditableRowKeys((prev) =>
prev.includes(mergedRecord.key)
? prev
: [...prev, mergedRecord.key],
);
},
onChange: setEditableRowKeys,
deleteText: (
<Button
type="primary"
danger
shape="circle"
icon={<DeleteOutlined />}
/>
),
}}
/>
</Modal>
);
};
export default CreateOrUpdateFishingLog;

View File

@@ -0,0 +1,83 @@
import { ProCard, ProColumns, ProTable } from '@ant-design/pro-components';
import { Form, Modal } from 'antd';
interface HaulFishListProp {
open: boolean;
onOpenChange: (open: boolean) => void;
fishList?: API.FishingLogInfo[];
}
const HaulFishList: React.FC<HaulFishListProp> = ({
open,
onOpenChange,
fishList,
}) => {
const [form] = Form.useForm();
const fish_columns: ProColumns<API.FishingLogInfo>[] = [
{
title: 'Tên cá',
dataIndex: 'fish_name',
key: 'fish_name',
render: (value) => value, // bạn có thể sửa render để hiển thị tên thiết bị nếu cần
},
{
title: 'Trạng thái',
dataIndex: 'fish_condition',
key: 'fish_condition',
render: (value) => value, // bạn có thể sửa render để hiển thị tên thiết bị nếu cần
},
{
title: 'Độ hiếm',
dataIndex: 'fish_rarity',
key: 'fish_rarity',
render: (value) => value, // bạn có thể sửa render để hiển thị tên thiết bị nếu cần
},
{
title: 'Kích thước (cm)',
dataIndex: 'fish_size',
key: 'fish_size',
render: (value) => value, // bạn có thể sửa render để hiển thị tên thiết bị nếu cần
},
{
title: 'Cân nặng (kg)',
dataIndex: 'catch_number',
key: 'catch_number',
render: (value) => value, // bạn có thể sửa render để hiển thị tên thiết bị nếu cần
},
{
title: 'Ngư cụ sử dụng',
dataIndex: 'gear_usage',
key: 'gear_usage',
render: (value) => value, // bạn có thể sửa render để hiển thị tên thiết bị nếu cần
},
];
return (
<Modal
title="Danh sách cá"
open={open}
footer={null}
closable
afterClose={() => onOpenChange(false)}
onCancel={() => onOpenChange(false)}
width={1000}
>
<ProCard split="vertical">
<ProTable<API.FishingLogInfo>
columns={fish_columns}
dataSource={fishList}
search={false}
pagination={{
pageSize: 5,
showSizeChanger: true,
}}
options={false}
bordered
size="middle"
scroll={{ x: 600 }}
/>
</ProCard>
</Modal>
);
};
export default HaulFishList;

View File

@@ -0,0 +1,183 @@
import { EditOutlined, EyeOutlined } from '@ant-design/icons';
import { ProColumns, ProTable } from '@ant-design/pro-components';
import { useIntl, useModel } from '@umijs/max';
import { Button, Flex, message } from 'antd';
import React, { useState } from 'react';
import CreateOrUpdateFishingLog from './CreateOrUpdateFishingLog';
import HaulFishList from './HaulFishList';
export interface HaulTableProps {
hauls: API.FishingLog[];
trip?: API.Trip;
onReload?: (isTrue: boolean) => void;
}
const HaulTable: React.FC<HaulTableProps> = ({ hauls, trip, onReload }) => {
const [editOpen, setEditOpen] = useState(false);
const [editFishingLogOpen, setEditFishingLogOpen] = useState(false);
const [currentRow, setCurrentRow] = useState<API.FishingLogInfo[]>([]);
const [currentFishingLog, setCurrentFishingLog] =
useState<API.FishingLog | null>(null);
const intl = useIntl();
const { getApi } = useModel('getTrip');
console.log('HaulTable received hauls:', hauls);
const fishing_logs_columns: ProColumns<API.FishingLog>[] = [
{
title: <div style={{ textAlign: 'center' }}>STT</div>,
dataIndex: 'fishing_log_id',
align: 'center',
render: (_, __, index, action) => {
return `Mẻ ${hauls.length - index}`;
},
},
{
title: <div style={{ textAlign: 'center' }}>Trạng Thái</div>,
dataIndex: ['status'], // 👈 lấy từ status 1: đang
align: 'center',
valueEnum: {
0: {
text: intl.formatMessage({
id: 'pages.trips.status.fishing',
defaultMessage: 'Đang đánh bắt',
}),
status: 'Processing',
},
1: {
text: intl.formatMessage({
id: 'pages.trips.status.end_fishing',
defaultMessage: 'Đã hoàn thành',
}),
status: 'Success',
},
2: {
text: intl.formatMessage({
id: 'pages.trips.status.cancel_fishing',
defaultMessage: 'Đã huỷ',
}),
status: 'default',
},
},
},
{
title: <div style={{ textAlign: 'center' }}>Thời tiết</div>,
dataIndex: ['weather_description'], // 👈 lấy từ weather
align: 'center',
},
{
title: <div style={{ textAlign: 'center' }}>Thời điểm bắt đu</div>,
dataIndex: ['start_at'], // birth_date là date of birth
align: 'center',
render: (start_at: any) => {
if (!start_at) return '-';
const date = new Date(start_at);
return date.toLocaleString('vi-VN', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
},
},
{
title: <div style={{ textAlign: 'center' }}>Thời điểm kết thúc</div>,
dataIndex: ['end_at'], // birth_date là date of birth
align: 'center',
render: (end_at: any) => {
// console.log('End at value:', end_at);
if (end_at == '0001-01-01T00:00:00Z') return '-';
const date = new Date(end_at);
return date.toLocaleString('vi-VN', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
},
},
{
title: 'Thao tác',
align: 'center',
hideInSearch: true,
render: (_, record) => {
console.log('Rendering action column for record:', record);
return (
<Flex align="center" justify="center" gap={5}>
{/* Nút Edit */}
<Button
shape="default"
size="small"
icon={<EyeOutlined />}
onClick={() => {
if (record.info) {
setCurrentRow(record.info!); // record là dòng hiện tại trong table
setEditOpen(true);
} else {
message.warning('Không có dữ liệu cá trong mẻ lưới này');
}
}}
/>
{editOpen && currentRow && (
<HaulFishList
fishList={currentRow} // truyền luôn cả record nếu cần
open={editOpen}
onOpenChange={setEditOpen}
/>
)}
<Button
shape="default"
size="small"
icon={<EditOutlined />}
onClick={() => {
setCurrentFishingLog(record);
setEditFishingLogOpen(true);
}}
/>
</Flex>
);
},
},
];
return (
<>
<ProTable<API.FishingLog>
style={{ width: '90%' }}
columns={fishing_logs_columns}
dataSource={hauls.slice().reverse()} // đảo ngược thứ tự hiển thị
search={false}
pagination={{
pageSize: 10,
showSizeChanger: true,
showTotal: (total, range) => `${range[0]}-${range[1]} trên ${total}`,
}}
options={false}
bordered
size="middle"
scroll={{ x: 600 }}
/>
<CreateOrUpdateFishingLog
trip={trip!}
isFinished={currentFishingLog?.status === 0 ? false : true}
fishingLogs={currentFishingLog || undefined}
isOpen={editFishingLogOpen}
onOpenChange={setEditFishingLogOpen}
onFinished={(success) => {
if (success) {
message.success('Cập nhật mẻ lưới thành công');
getApi();
} else {
message.error('Cập nhật mẻ lưới thất bại');
}
}}
/>
</>
);
};
export default HaulTable;

View File

@@ -0,0 +1,77 @@
import { Col, Row, Skeleton } from 'antd';
const ListSkeleton = ({}) => {
return (
<>
<Row justify="space-between">
<Col span={2}>
<Skeleton.Avatar active={true} size="small" />
</Col>
<Col span={16}>
<Skeleton.Input active={true} size="small" block={true} />
</Col>
<Col span={4}>
<Skeleton.Button
active={true}
size="small"
shape="round"
block={true}
/>
</Col>
</Row>
<p />
<Row justify="space-between">
<Col span={2}>
<Skeleton.Avatar active={true} size="small" />
</Col>
<Col span={16}>
<Skeleton.Input active={true} size="small" block={true} />
</Col>
<Col span={4}>
<Skeleton.Button
active={true}
size="small"
shape="round"
block={true}
/>
</Col>
</Row>
<p />
<Row justify="space-between">
<Col span={2}>
<Skeleton.Avatar active={true} size="small" />
</Col>
<Col span={16}>
<Skeleton.Input active={true} size="small" block={true} />
</Col>
<Col span={4}>
<Skeleton.Button
active={true}
size="small"
shape="round"
block={true}
/>
</Col>
</Row>
<p />
<Row justify="space-between">
<Col span={2}>
<Skeleton.Avatar active={true} size="small" />
</Col>
<Col span={16}>
<Skeleton.Input active={true} size="small" block={true} />
</Col>
<Col span={4}>
<Skeleton.Button
active={true}
size="small"
shape="round"
block={true}
/>
</Col>
</Row>
<p />
</>
);
};
export default ListSkeleton;

View File

@@ -0,0 +1,158 @@
import { ProCard } from '@ant-design/pro-components';
import { useModel } from '@umijs/max';
import { Flex } from 'antd';
import HaulTable from './HaulTable';
import TripCostTable from './TripCost';
import TripCrews from './TripCrews';
import TripFishingGearTable from './TripFishingGear';
// Props cho component
interface MainTripBodyProps {
trip_id?: string;
tripInfo: API.Trip | null;
onReload?: (isTrue: boolean) => void;
}
const MainTripBody: React.FC<MainTripBodyProps> = ({
trip_id,
tripInfo,
onReload,
}) => {
// console.log('MainTripBody received:');
// console.log("trip_id:", trip_id);
// console.log('tripInfo:', tripInfo);
const { data, getApi } = useModel('getTrip');
const tripCosts = Array.isArray(tripInfo?.trip_cost)
? tripInfo.trip_cost
: [];
const fishingGears = Array.isArray(tripInfo?.fishing_gears)
? tripInfo.fishing_gears
: [];
const fishing_logs_columns = [
{
title: <div style={{ textAlign: 'center' }}>Tên</div>,
dataIndex: 'name',
valueType: 'select',
align: 'center',
},
{
title: <div style={{ textAlign: 'center' }}>Số lượng</div>,
dataIndex: 'number',
align: 'center',
},
];
const tranship_columns = [
{
title: <div style={{ textAlign: 'center' }}>Tên</div>,
dataIndex: 'name',
valueType: 'select',
align: 'center',
},
{
title: <div style={{ textAlign: 'center' }}>Chức vụ</div>,
dataIndex: 'role',
align: 'center',
},
];
return (
<Flex gap={10}>
<ProCard
ghost
gutter={{
xs: 8,
sm: 8,
md: 8,
lg: 8,
xl: 8,
xxl: 8,
}}
direction="column"
bodyStyle={{
padding: 0,
paddingInline: 0,
gap: 10,
}}
>
<ProCard bodyStyle={{ padding: 0, gap: 5 }}>
<ProCard
colSpan={{ xs: 2, sm: 4, md: 6, lg: 8, xl: 12 }}
layout="center"
bordered
headStyle={{
textAlign: 'center',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
borderBottom: '1px solid #f0f0f0',
}}
title="Chi phí chuyến đi"
style={{ minHeight: 300 }}
>
<TripCostTable tripCosts={tripCosts} />
</ProCard>
<ProCard
colSpan={{ xs: 2, sm: 4, md: 6, lg: 8, xl: 12 }}
layout="center"
bordered
headStyle={{
textAlign: 'center',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
borderBottom: '1px solid #f0f0f0',
}}
title="Danh sách ngư cụ"
style={{ minHeight: 300 }}
>
<TripFishingGearTable fishingGears={fishingGears} />
</ProCard>
</ProCard>
<ProCard
style={{ paddingInlineEnd: 0 }}
ghost={true}
colSpan={{ xs: 4, sm: 8, md: 12, lg: 16, xl: 24 }}
layout="center"
bordered
headStyle={{
textAlign: 'center',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}
title="Danh sách thuyền viên"
>
<TripCrews crew={tripInfo?.crews} />
</ProCard>
<ProCard
style={{ paddingInlineEnd: 0 }}
ghost={true}
colSpan={{ xs: 4, sm: 8, md: 12, lg: 16, xl: 24 }}
layout="center"
bordered
headStyle={{
textAlign: 'center',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}
title="Danh sách mẻ lưới"
>
<HaulTable
trip={tripInfo!}
hauls={tripInfo?.fishing_logs || []}
onReload={(isTrue) => {
if (isTrue) {
// onReload?.(true);
getApi();
}
}}
/>
</ProCard>
</ProCard>
</Flex>
);
};
export default MainTripBody;

View File

@@ -0,0 +1,60 @@
import { updateTripState } from '@/services/controller/TripController';
import { useModel } from '@umijs/max';
import { Button, message, Popconfirm } from 'antd';
import React from 'react';
import CancelTrip from './CancelTrip';
interface TripCancleOrFinishedButtonProps {
tripStatus?: number;
onCallBack?: (success: boolean) => void;
}
const TripCancleOrFinishedButton: React.FC<TripCancleOrFinishedButtonProps> = ({
tripStatus,
onCallBack,
}) => {
const { getApi } = useModel('getTrip');
const handleClickButton = async (state: number, note?: string) => {
try {
const resp = await updateTripState({ status: state, note: note || '' });
message.success('Cập nhật trạng thái thành công');
getApi();
onCallBack?.(true);
} catch (error) {
console.error('Error updating trip status:', error);
message.error('Cập nhật trạng thái thất bại');
onCallBack?.(false);
}
};
const renderButton = () => {
switch (tripStatus) {
case 3: // Đang hoạt động
return (
<div className="flex gap-3">
<CancelTrip
onFinished={async (note) => {
await handleClickButton(5, note);
}}
/>
<Popconfirm
title="Thông báo"
description="Bạn chắc chắn muốn kết thúc chuyến đi?"
onConfirm={async () => handleClickButton(4)}
okText="Chắc chắn"
cancelText="Không"
>
<Button color="orange" variant="solid">
Kết thúc
</Button>
</Popconfirm>
</div>
);
default:
return null;
}
};
return <div>{renderButton()}</div>;
};
export default TripCancleOrFinishedButton;

View File

@@ -0,0 +1,86 @@
import { ProColumns, ProTable } from '@ant-design/pro-components';
import { Typography } from 'antd';
interface TripCostTableProps {
tripCosts: API.TripCost[];
}
const TripCostTable: React.FC<TripCostTableProps> = ({ tripCosts }) => {
// Tính tổng chi phí
const total_trip_cost = tripCosts.reduce(
(sum, item) => sum + (Number(item.total_cost) || 0),
0,
);
const trip_cost_columns: ProColumns<API.TripCost>[] = [
{
title: <div style={{ textAlign: 'center' }}>Loại</div>,
dataIndex: 'type',
valueEnum: {
fuel: { text: 'Nhiên liệu' },
crew_salary: { text: 'Lương thuyền viên' },
food: { text: 'Lương thực' },
ice_salt_cost: { text: 'Muối đá' },
},
align: 'center',
},
{
title: <div style={{ textAlign: 'center' }}>Số lượng</div>,
dataIndex: 'amount',
align: 'center',
},
{
title: <div style={{ textAlign: 'center' }}>Đơn vị</div>,
dataIndex: 'unit',
align: 'center',
},
{
title: <div style={{ textAlign: 'center' }}>Chi phí</div>,
dataIndex: 'cost_per_unit',
align: 'center',
render: (val: any) => (val ? Number(val).toLocaleString() : ''),
},
{
title: <div style={{ textAlign: 'center' }}>Tổng chi phí</div>,
dataIndex: 'total_cost',
align: 'center',
render: (val: any) =>
val ? (
<b style={{ color: '#fa541c' }}>{Number(val).toLocaleString()}</b>
) : (
''
),
},
];
return (
<ProTable<API.TripCost>
columns={trip_cost_columns}
dataSource={tripCosts}
search={false}
pagination={{
pageSize: 5,
showSizeChanger: true,
showTotal: (total, range) => `${range[0]}-${range[1]} trên ${total}`,
}}
options={false}
bordered
size="middle"
scroll={{ x: 600 }}
summary={() => (
<ProTable.Summary.Row>
<ProTable.Summary.Cell index={0} colSpan={4} align="right">
<Typography.Text strong style={{ color: '#1890ff' }}>
Tổng cộng
</Typography.Text>
</ProTable.Summary.Cell>
<ProTable.Summary.Cell index={4} align="center">
<Typography.Text strong style={{ color: '#fa541c', fontSize: 16 }}>
{total_trip_cost.toLocaleString()}
</Typography.Text>
</ProTable.Summary.Cell>
</ProTable.Summary.Row>
)}
/>
);
};
export default TripCostTable;

View File

@@ -0,0 +1,84 @@
import { ProColumns, ProTable } from '@ant-design/pro-components';
import React from 'react';
interface TripCrewsProps {
crew?: API.TripCrews[];
}
const TripCrews: React.FC<TripCrewsProps> = ({ crew }) => {
console.log('TripCrews received crew:', crew);
const crew_columns: ProColumns<API.TripCrews>[] = [
{
title: <div style={{ textAlign: 'center' }}> đnh danh</div>,
dataIndex: ['Person', 'personal_id'], // 👈 lấy từ Person.personal_id
align: 'center',
},
{
title: <div style={{ textAlign: 'center' }}>Tên</div>,
dataIndex: ['Person', 'name'], // 👈 lấy từ Person.name
align: 'center',
},
{
title: <div style={{ textAlign: 'center' }}>Chức vụ</div>,
dataIndex: 'role',
align: 'center',
render: (val: any) => {
switch (val) {
case 'captain':
return <span style={{ color: 'blue' }}>Thuyền trưởng</span>;
case 'crew':
return <span style={{ color: 'green' }}>Thuyền viên</span>;
default:
return <span>{val}</span>;
}
},
},
{
title: <div style={{ textAlign: 'center' }}>Email</div>,
dataIndex: ['Person', 'email'], // 👈 lấy từ Person.email
align: 'center',
},
{
title: <div style={{ textAlign: 'center' }}>Số điện thoại</div>,
dataIndex: ['Person', 'phone'], // 👈 lấy từ Person.phone
align: 'center',
},
{
title: <div style={{ textAlign: 'center' }}>Ngày sinh</div>,
dataIndex: ['Person', 'birth_date'], // birth_date là date of birth
align: 'center',
render: (birth_date: any) => {
if (!birth_date) return '-';
const date = new Date(birth_date);
return date.toLocaleDateString('vi-VN'); // 👉 tự động ra dd/mm/yyyy
},
},
{
title: <div style={{ textAlign: 'center' }}>Đa chỉ</div>,
dataIndex: ['Person', 'address'], // 👈 lấy từ Person.address
align: 'center',
},
];
return (
<ProTable<API.TripCrews>
style={{ width: '90%' }}
columns={crew_columns}
dataSource={crew}
search={false}
rowKey={(record, idx: any) => record.Person.personal_id || idx.toString()}
pagination={{
pageSize: 5,
showSizeChanger: true,
showTotal: (total, range) => `${range[0]}-${range[1]} trên ${total}`,
}}
options={false}
bordered
size="middle"
scroll={{ x: 600 }}
/>
);
};
export default TripCrews;

View File

@@ -0,0 +1,44 @@
import { ProColumns, ProTable } from '@ant-design/pro-components';
import React from 'react';
interface TripFishingGearTableProps {
fishingGears: API.FishingGear[];
}
const TripFishingGearTable: React.FC<TripFishingGearTableProps> = ({
fishingGears,
}) => {
const fishing_gears_columns: ProColumns<API.FishingGear>[] = [
{
title: <div style={{ textAlign: 'center' }}>Tên</div>,
dataIndex: 'name',
valueType: 'select',
align: 'center',
},
{
title: <div style={{ textAlign: 'center' }}>Số lượng</div>,
dataIndex: 'number',
align: 'center',
},
];
return (
<ProTable<API.FishingGear>
columns={fishing_gears_columns}
dataSource={fishingGears}
search={false}
pagination={{
pageSize: 5,
showSizeChanger: true,
showTotal: (total, range) => `${range[0]}-${range[1]} trên ${total}`,
}}
options={false}
bordered
size="middle"
scroll={{ x: 600 }}
style={{ flex: 1 }}
/>
);
};
export default TripFishingGearTable;