thêm giao diện cảnh báo

This commit is contained in:
Tran Anh Tuan
2025-12-09 11:37:19 +07:00
parent 7c3497d159
commit df4318fed4
14 changed files with 2546 additions and 1205 deletions

View File

@@ -0,0 +1,439 @@
import {
queryConfirmAlarm,
queryrUnconfirmAlarm,
} from "@/controller/AlarmController";
import { useThemeContext } from "@/hooks/use-theme-context";
import { Ionicons } from "@expo/vector-icons";
import dayjs from "dayjs";
import React, { useMemo, useState } from "react";
import {
ActivityIndicator,
Alert,
Modal,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from "react-native";
interface AlarmCardProps {
alarm: Model.Alarm;
onReload?: (onReload: boolean) => void;
}
export const AlarmCard: React.FC<AlarmCardProps> = ({ alarm, onReload }) => {
const { colors } = useThemeContext();
const [showModal, setShowModal] = useState(false);
const [note, setNote] = useState("");
const [submitting, setSubmitting] = useState(false);
const canSubmit = useMemo(
() => note.trim().length > 0 || alarm.confirmed,
[note, alarm.confirmed]
);
// Determine level and colors based on alarm level
const getAlarmConfig = (level?: number) => {
if (level === 3) {
// Danger - Red
return {
level: 3,
icon: "warning" as const,
bgColor: "#fee2e2",
borderColor: "#DC0E0E",
iconColor: "#dc2626",
statusBg: "#dcfce7",
statusText: "#166534",
};
} else if (level === 2) {
// Caution - Yellow/Orange
return {
level: 2,
icon: "alert-circle" as const,
bgColor: "#fef3c7",
borderColor: "#FF6C0C",
iconColor: "#d97706",
statusBg: "#fef08a",
statusText: "#713f12",
};
} else {
// Info - Green
return {
level: 1,
icon: "information-circle" as const,
bgColor: "#fffefe",
borderColor: "#FF937E",
iconColor: "#FF937E",
statusBg: "#dcfce7",
statusText: "#166534",
};
}
};
const config = getAlarmConfig(alarm.level);
const formatDate = (timestamp?: number) => {
if (!timestamp) return "N/A";
return dayjs.unix(timestamp).format("YYYY-MM-DD HH:mm");
};
const ensurePayload = () => {
if (!alarm.id || !alarm.thing_id || !alarm.time) {
Alert.alert("Thiếu dữ liệu", "Không đủ thông tin để xác nhận cảnh báo");
return false;
}
return true;
};
const submitConfirm = async (action: "confirm" | "unconfirm") => {
if (!ensurePayload()) return;
if (action === "confirm" && note.trim().length === 0) {
Alert.alert("Thông báo", "Vui lòng nhập ghi chú để xác nhận");
return;
}
try {
setSubmitting(true);
if (action === "confirm") {
await queryConfirmAlarm({
id: alarm.id!,
thing_id: alarm.thing_id!,
time: alarm.time!,
description: note.trim(),
});
} else {
await queryrUnconfirmAlarm({
id: alarm.id!,
thing_id: alarm.thing_id!,
time: alarm.time!,
});
}
onReload?.(true);
} catch (error: any) {
console.error("Cannot confirm/unconfirm alarm: ", error);
const status = error?.response?.status ?? error?.status;
// If server returns 404, ignore silently
if (status !== 404) {
Alert.alert("Lỗi", "Không thể xử lý yêu cầu. Vui lòng thử lại.");
}
} finally {
setSubmitting(false);
setShowModal(false);
setNote("");
}
};
const handlePress = (alarm: Model.Alarm) => {
if (alarm.confirmed) {
Alert.alert(
"Thông báo",
"Bạn có chắc muốn ngừng xác nhận cảnh báo này?",
[
{ text: "Hủy", style: "cancel" },
{
text: "Ngừng xác nhận",
style: "destructive",
onPress: () => submitConfirm("unconfirm"),
},
]
);
} else {
setShowModal(true);
}
};
return (
<View
style={[
styles.card,
{
backgroundColor: config.bgColor,
borderLeftColor: config.borderColor,
borderLeftWidth: 5,
boxShadow: "0px 1px 3px rgba(0, 0, 0, 0.2)",
},
]}
>
<View style={styles.container}>
{/* Left Side - Icon and Content */}
<View style={styles.content}>
{/* Icon */}
<View
style={[styles.iconContainer, { backgroundColor: config.bgColor }]}
>
<Ionicons name={config.icon} size={24} color={config.iconColor} />
</View>
{/* Title and Info */}
<View style={styles.textContainer}>
{/* Name */}
<View style={styles.titleRow}>
<Text
style={[styles.title, { color: colors.text }]}
numberOfLines={2}
>
{alarm.name || alarm.thing_name || "Unknown"}
</Text>
</View>
{/* Location (thing_name) and Time */}
<View style={styles.infoRow}>
<View style={styles.infoItem}>
<Text
style={[styles.infoLabel, { color: colors.textSecondary }]}
>
Trạm
</Text>
<Text
style={[styles.infoValue, { color: colors.text }]}
numberOfLines={1}
>
{alarm.thing_name || "Unknown"}
</Text>
</View>
<View style={styles.infoItem}>
<Text
style={[styles.infoLabel, { color: colors.textSecondary }]}
>
Thời gian
</Text>
<Text
style={[styles.infoValue, { color: colors.text }]}
numberOfLines={1}
>
{formatDate(alarm.time)}
</Text>
</View>
</View>
{/* Status Badge */}
<TouchableOpacity
style={styles.statusContainer}
onPress={() => handlePress(alarm)}
activeOpacity={0.7}
>
<View
style={[
styles.statusBadge,
{
backgroundColor: alarm.confirmed ? "#8FD14F" : "#EEEEEE",
},
]}
>
<Text
style={[
styles.statusText,
{ color: alarm.confirmed ? "#166534" : "black" },
]}
>
{alarm.confirmed ? "Đã xác nhận" : "Chờ xác nhận"}
</Text>
</View>
</TouchableOpacity>
</View>
</View>
{alarm.confirmed && (
<View style={styles.rightIcon}>
<Ionicons
name="checkmark-done"
size={20}
color={alarm.confirmed ? "#78C841" : config.iconColor}
/>
</View>
)}
</View>
<Modal
visible={showModal}
transparent
animationType="fade"
onRequestClose={() => setShowModal(false)}
>
<View style={styles.modalOverlay}>
<View
style={[
styles.modalContent,
{ backgroundColor: colors.background },
]}
>
<Text style={[styles.modalTitle, { color: colors.text }]}>
Nhập ghi chú xác nhận
</Text>
<TextInput
style={[styles.input, { color: colors.text }]}
placeholder="Nhập ghi chú"
placeholderTextColor={colors.textSecondary}
multiline
value={note}
onChangeText={setNote}
editable={!submitting}
/>
<View style={styles.modalActions}>
<TouchableOpacity
style={[styles.modalButton, styles.cancelButton]}
onPress={() => {
setShowModal(false);
setNote("");
}}
disabled={submitting}
>
<Text style={styles.cancelText}>Hủy</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.modalButton,
styles.confirmButton,
!canSubmit && styles.disabledButton,
]}
onPress={() => submitConfirm("confirm")}
disabled={submitting || !canSubmit}
>
{submitting ? (
<ActivityIndicator color="#fff" size="small" />
) : (
<Text style={styles.confirmText}>Xác nhận</Text>
)}
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
</View>
);
};
const styles = StyleSheet.create({
card: {
borderRadius: 12,
// borderWidth: 1,
paddingVertical: 16,
paddingHorizontal: 12,
marginBottom: 12,
},
container: {
flexDirection: "row",
alignItems: "flex-start",
justifyContent: "space-between",
},
content: {
flex: 1,
flexDirection: "row",
alignItems: "flex-start",
},
iconContainer: {
width: 48,
height: 48,
borderRadius: 12,
alignItems: "flex-start",
justifyContent: "flex-start",
// marginRight: 5,
},
textContainer: {
flex: 1,
},
titleRow: {
marginBottom: 8,
},
code: {
fontSize: 12,
fontWeight: "600",
marginBottom: 4,
},
title: {
fontSize: 16,
fontWeight: "600",
marginBottom: 8,
},
infoRow: {
flexDirection: "row",
justifyContent: "space-between",
marginBottom: 12,
gap: 16,
},
infoItem: {
flex: 1,
},
infoLabel: {
fontSize: 12,
marginBottom: 4,
},
infoValue: {
fontSize: 14,
fontWeight: "500",
},
statusContainer: {
marginTop: 8,
},
statusBadge: {
alignSelf: "flex-start",
paddingVertical: 6,
paddingHorizontal: 12,
borderRadius: 20,
borderWidth: 0.2,
},
statusText: {
fontSize: 12,
fontWeight: "600",
},
rightIcon: {
width: 24,
height: 24,
alignItems: "center",
justifyContent: "center",
marginLeft: 12,
},
modalOverlay: {
flex: 1,
backgroundColor: "rgba(0,0,0,0.3)",
justifyContent: "center",
paddingHorizontal: 16,
},
modalContent: {
borderRadius: 12,
padding: 16,
gap: 12,
},
modalTitle: {
fontSize: 16,
fontWeight: "700",
},
input: {
minHeight: 80,
borderRadius: 8,
borderWidth: 1,
borderColor: "#e5e7eb",
padding: 12,
textAlignVertical: "top",
},
modalActions: {
flexDirection: "row",
justifyContent: "flex-end",
gap: 12,
},
modalButton: {
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 8,
},
cancelButton: {
backgroundColor: "#e5e7eb",
},
confirmButton: {
backgroundColor: "#dc2626",
},
disabledButton: {
opacity: 0.6,
},
cancelText: {
color: "#111827",
fontWeight: "600",
},
confirmText: {
color: "#fff",
fontWeight: "700",
},
});
export default AlarmCard;

View File

@@ -0,0 +1,305 @@
import Select, { SelectOption } from "@/components/Select";
import { ThemedText } from "@/components/themed-text";
import { ThemedView } from "@/components/themed-view";
import { useThemeContext } from "@/hooks/use-theme-context";
import { Ionicons } from "@expo/vector-icons";
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { StyleSheet, TextInput, TouchableOpacity, View } from "react-native";
interface AlarmSearchFormProps {
initialValue?: {
name?: string;
level?: number;
confirmed?: boolean;
};
onSubmit: (payload: {
name?: string;
level?: number;
confirmed?: boolean;
}) => void;
onReset?: () => void;
}
interface FormData {
name: string;
level: number;
confirmed: string; // Using string for Select component compatibility
}
const AlarmSearchForm: React.FC<AlarmSearchFormProps> = ({
initialValue,
onSubmit,
onReset,
}) => {
const { colors } = useThemeContext();
const levelOptions: SelectOption[] = [
{ label: "Tất cả", value: 0 },
{ label: "Cảnh báo", value: 1 },
{ label: "Nguy hiểm", value: 2 },
];
const confirmedOptions: SelectOption[] = [
{ label: "Tất cả", value: "" },
{ label: "Đã xác nhận", value: "true" },
{ label: "Chưa xác nhận", value: "false" },
];
const { control, handleSubmit, reset } = useForm<FormData>({
defaultValues: {
name: initialValue?.name || "",
level: initialValue?.level || 0,
confirmed:
initialValue?.confirmed !== undefined
? initialValue.confirmed.toString()
: "",
},
});
useEffect(() => {
if (initialValue) {
reset({
name: initialValue.name || "",
level: initialValue.level || 0,
confirmed:
initialValue.confirmed !== undefined
? initialValue.confirmed.toString()
: "",
});
}
}, [initialValue, reset]);
const onFormSubmit = (data: FormData) => {
const payload: {
name?: string;
level?: number;
confirmed?: boolean;
} = {
...(data.name && { name: data.name }),
...(data.level !== 0 && { level: data.level }),
...(data.confirmed !== "" && {
confirmed: data.confirmed === "true",
}),
};
onSubmit(payload);
};
const handleReset = () => {
reset({
name: "",
level: 0,
confirmed: undefined,
});
// Submit empty payload to reset filters
onSubmit({});
onReset?.();
};
return (
<ThemedView
style={[
styles.container,
{
backgroundColor: colors.background,
borderBottomColor: colors.border,
height: "auto",
},
]}
>
<View style={styles.content}>
{/* Search Input */}
<Controller
control={control}
name="name"
render={({ field: { onChange, onBlur, value } }) => (
<View style={styles.inputContainer}>
<ThemedText style={styles.label}>Tìm kiếm</ThemedText>
<View
style={[styles.inputWrapper, { borderColor: colors.border }]}
>
<TextInput
style={[styles.input, { color: colors.text }]}
placeholder="Tìm theo tên cảnh báo"
placeholderTextColor={colors.textSecondary}
value={value}
onChangeText={onChange}
onBlur={onBlur}
/>
{value ? (
<TouchableOpacity
onPress={() => onChange("")}
style={styles.clearButton}
>
<Ionicons
name="close-circle"
size={20}
color={colors.textSecondary}
/>
</TouchableOpacity>
) : null}
</View>
</View>
)}
/>
{/* Level and Confirmed Selects */}
<View style={styles.row}>
<View style={styles.halfWidth}>
<Controller
control={control}
name="level"
render={({ field: { onChange, value } }) => (
<View style={styles.selectContainer}>
<ThemedText style={styles.label}>Mức đ</ThemedText>
<Select
placeholder="Chọn mức độ"
value={value}
onChange={onChange}
options={levelOptions}
size="middle"
/>
</View>
)}
/>
</View>
<View style={styles.halfWidth}>
<Controller
control={control}
name="confirmed"
render={({ field: { onChange, value } }) => (
<View style={styles.selectContainer}>
<ThemedText style={styles.label}>Trạng thái</ThemedText>
<Select
placeholder="Chọn trạng thái"
value={value}
onChange={onChange}
options={confirmedOptions}
size="middle"
/>
</View>
)}
/>
</View>
</View>
{/* Action Buttons */}
<View style={styles.buttonRow}>
<TouchableOpacity
style={[
styles.button,
styles.secondaryButton,
{
backgroundColor: colors.backgroundSecondary,
borderColor: colors.border,
},
]}
onPress={handleReset}
>
<ThemedText style={[styles.buttonText, { color: colors.text }]}>
Đt lại
</ThemedText>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.button,
styles.primaryButton,
{ backgroundColor: colors.primary },
]}
onPress={handleSubmit(onFormSubmit)}
>
<ThemedText style={[styles.buttonText, { color: "#fff" }]}>
Tìm kiếm
</ThemedText>
</TouchableOpacity>
</View>
</View>
</ThemedView>
);
};
const styles = StyleSheet.create({
container: {
borderBottomWidth: 1,
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.1,
shadowRadius: 3.84,
elevation: 5,
zIndex: 100,
},
content: {
padding: 16,
overflow: "visible",
},
inputContainer: {
marginBottom: 16,
},
label: {
fontSize: 14,
fontWeight: "500",
marginBottom: 6,
},
inputWrapper: {
flexDirection: "row",
alignItems: "center",
borderWidth: 1,
borderRadius: 8,
paddingHorizontal: 12,
},
input: {
flex: 1,
height: 40,
fontSize: 16,
},
clearButton: {
marginLeft: 8,
padding: 4,
},
row: {
flexDirection: "row",
justifyContent: "space-between",
marginBottom: 16,
zIndex: 10,
},
halfWidth: {
width: "48%",
zIndex: 5000,
},
selectContainer: {
// flex: 1, // Remove this to prevent taking full width
zIndex: 5000,
},
buttonRow: {
flexDirection: "row",
justifyContent: "space-between",
gap: 12,
marginTop: 16,
},
button: {
flex: 1,
height: 40,
borderRadius: 8,
justifyContent: "center",
alignItems: "center",
},
secondaryButton: {
borderWidth: 1,
},
primaryButton: {
// backgroundColor is set dynamically
},
buttonText: {
fontSize: 16,
fontWeight: "600",
},
});
export default AlarmSearchForm;

View File

@@ -1,197 +0,0 @@
import { Ionicons } from "@expo/vector-icons";
import dayjs from "dayjs";
import { FlatList, Text, TouchableOpacity, View } from "react-native";
export type AlarmStatus = "confirmed" | "pending";
export interface AlarmListItem {
id: string;
code: string;
title: string;
station: string;
timestamp: number;
level: 1 | 2 | 3; // 1: warning (yellow), 2: caution (orange/yellow), 3: danger (red)
status: AlarmStatus;
}
type AlarmProp = {
alarmsData: AlarmListItem[];
onPress?: (alarm: AlarmListItem) => void;
};
const AlarmList = ({ alarmsData, onPress }: AlarmProp) => {
return (
<FlatList
data={alarmsData}
contentContainerStyle={{ paddingHorizontal: 16, paddingVertical: 8 }}
ItemSeparatorComponent={() => <View className="h-3" />}
renderItem={({ item }) => (
<AlarmCard alarm={item} onPress={() => onPress?.(item)} />
)}
keyExtractor={(item) => item.id}
showsVerticalScrollIndicator={false}
/>
);
};
type AlarmCardProps = {
alarm: AlarmListItem;
onPress?: () => void;
};
const AlarmCard = ({ alarm, onPress }: AlarmCardProps) => {
const { bgColor, borderColor, iconColor, iconBgColor } = getColorsByLevel(
alarm.level
);
const statusConfig = getStatusConfig(alarm.status);
return (
<TouchableOpacity
onPress={onPress}
activeOpacity={0.7}
className={`rounded-xl p-4 ${bgColor} ${borderColor} border`}
>
<View className="flex-row justify-between items-start">
{/* Left content */}
<View className="flex-row flex-1">
{/* Icon */}
<View
className={`w-10 h-10 rounded-full items-center justify-center mr-3 ${iconBgColor}`}
>
<Ionicons
name={getIconByLevel(alarm.level)}
size={20}
color={iconColor}
/>
</View>
{/* Info */}
<View className="flex-1">
{/* Code */}
<Text
className={`text-xs font-medium mb-1 ${getCodeTextColor(
alarm.level
)}`}
>
{alarm.code}
</Text>
{/* Title */}
<Text className="text-base font-semibold text-gray-800 mb-2">
{alarm.title}
</Text>
{/* Station and Time */}
<View className="flex-row">
<View className="mr-6">
<Text className="text-xs text-gray-400 mb-0.5">Trạm</Text>
<Text className="text-sm text-gray-600">{alarm.station}</Text>
</View>
<View>
<Text className="text-xs text-gray-400 mb-0.5">Thời gian</Text>
<Text className="text-sm text-gray-600">
{formatTimestamp(alarm.timestamp)}
</Text>
</View>
</View>
{/* Status Badge */}
{/* <View className="mt-3">
<View
className={`self-start px-3 py-1.5 rounded-full ${statusConfig.bgColor}`}
>
<Text
className={`text-xs font-medium ${statusConfig.textColor}`}
>
{statusConfig.label}
</Text>
</View>
</View> */}
</View>
</View>
{/* Checkmark for confirmed */}
{/* {alarm.status === "confirmed" && (
<View className="w-6 h-6 rounded-full bg-green-500 items-center justify-center">
<Ionicons name="checkmark" size={16} color="white" />
</View>
)} */}
</View>
</TouchableOpacity>
);
};
const getColorsByLevel = (level: number) => {
switch (level) {
case 3: // Danger - Red
return {
bgColor: "bg-red-50",
borderColor: "border-red-200",
iconColor: "#DC2626",
iconBgColor: "bg-red-100",
};
case 2: // Caution - Yellow/Orange
return {
bgColor: "bg-yellow-50",
borderColor: "border-yellow-200",
iconColor: "#CA8A04",
iconBgColor: "bg-yellow-100",
};
case 1: // Info - Green
default:
return {
bgColor: "bg-green-50",
borderColor: "border-green-200",
iconColor: "#16A34A",
iconBgColor: "bg-green-100",
};
}
};
const getIconByLevel = (level: number): keyof typeof Ionicons.glyphMap => {
switch (level) {
case 3:
return "warning";
case 2:
return "alert-circle";
case 1:
default:
return "checkmark-circle";
}
};
const getCodeTextColor = (level: number) => {
switch (level) {
case 3:
return "text-red-600";
case 2:
return "text-yellow-600";
case 1:
default:
return "text-green-600";
}
};
const getStatusConfig = (status: AlarmStatus) => {
switch (status) {
case "confirmed":
return {
label: "Đã xác nhận",
bgColor: "bg-green-100",
textColor: "text-green-700",
};
case "pending":
default:
return {
label: "Chờ xác nhận",
bgColor: "bg-yellow-100",
textColor: "text-yellow-700",
};
}
};
const formatTimestamp = (timestamp: number) => {
return dayjs.unix(timestamp).format("YYYY-MM-DD HH:mm");
};
export default AlarmList;