380 lines
14 KiB
TypeScript
380 lines
14 KiB
TypeScript
import React, { useState, useEffect, useMemo } from 'react';
|
|
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Profile, PlotStatus, TemperatureProfileCommand } from '@/types';
|
|
import BezierEditor from '@/components/profiles/bezier/BezierEditor';
|
|
import { Edit, Trash2, Play, Pause, StopCircle, Copy, CopyPlus } from 'lucide-react';
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { Label } from "@/components/ui/label";
|
|
import { T, translate } from '../../i18n';
|
|
import { useModbus } from '../../contexts/ModbusContext';
|
|
import { getSlaveIdFromGroup, findCoilForProfile } from '../../lib/controllerUtils';
|
|
import { PV_REGISTER_NAME_SUFFIX, PROFILE_REGISTER_NAMES } from '../../constants';
|
|
import { useNavigate } from 'react-router-dom';
|
|
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select';
|
|
|
|
interface ProfileCardProps {
|
|
profile: Profile;
|
|
onDelete: (id: string) => void;
|
|
onCommand: (profileSlot: number, command: TemperatureProfileCommand) => void;
|
|
zones?: { id: string, name: string }[];
|
|
onApplyToZone?: (profileId: string, zoneId: string) => void;
|
|
onDuplicate: (profileToDuplicate: Profile) => void;
|
|
onCopyTo: (profileToCopy: Profile) => void;
|
|
canDuplicate?: boolean;
|
|
}
|
|
|
|
const formatDuration = (ms: number): string => {
|
|
if (isNaN(ms) || ms < 0) return '00h 00min 00s';
|
|
const totalSeconds = Math.floor(ms / 1000);
|
|
const hours = Math.floor(totalSeconds / 3600);
|
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
const seconds = totalSeconds % 60;
|
|
|
|
// For durations less than 1 hour, show minutes and seconds
|
|
if (hours === 0) {
|
|
const paddedMinutes = minutes.toString().padStart(2, '0');
|
|
const paddedSeconds = seconds.toString().padStart(2, '0');
|
|
return `${paddedMinutes}min ${paddedSeconds}s`;
|
|
}
|
|
|
|
// For durations 1+ hours, show hours and minutes
|
|
const paddedHours = hours.toString().padStart(2, '0');
|
|
const paddedMinutes = minutes.toString().padStart(2, '0');
|
|
return `${paddedHours}h ${paddedMinutes}min`;
|
|
};
|
|
|
|
// Helper function to get status text
|
|
const getStatusText = (status: PlotStatus): string => {
|
|
switch (status) {
|
|
case PlotStatus.IDLE:
|
|
return 'Idle';
|
|
case PlotStatus.RUNNING:
|
|
return 'Running';
|
|
case PlotStatus.INITIALIZING:
|
|
return 'Warmup';
|
|
case PlotStatus.PAUSED:
|
|
return 'Paused';
|
|
case PlotStatus.FINISHED:
|
|
return 'Finished';
|
|
case PlotStatus.STOPPED:
|
|
return 'Stopped';
|
|
default:
|
|
return 'Unknown';
|
|
}
|
|
};
|
|
|
|
const ProfileCard: React.FC<ProfileCardProps> = ({
|
|
profile,
|
|
onDelete,
|
|
onCommand,
|
|
zones,
|
|
onApplyToZone,
|
|
onDuplicate,
|
|
onCopyTo,
|
|
canDuplicate
|
|
}) => {
|
|
const navigate = useNavigate();
|
|
const profileId = String(profile.slot);
|
|
const [plainTextDescription, setPlainTextDescription] = useState('');
|
|
const { registers, settings, coils, updateCoil } = useModbus();
|
|
const [isToggling, setIsToggling] = useState(false);
|
|
|
|
const enableCoil = useMemo(() => {
|
|
if (!coils || !profile.name) return null;
|
|
return findCoilForProfile(
|
|
coils,
|
|
profile.name,
|
|
profile.slot,
|
|
PROFILE_REGISTER_NAMES.ENABLED
|
|
);
|
|
}, [coils, profile.name, profile.slot]);
|
|
|
|
const isEnabled = enableCoil ? enableCoil.value : false;
|
|
// console.log("isEnabled",profile.enabled,enableCoil);
|
|
|
|
const handleToggle = async (newState: boolean) => {
|
|
if (!enableCoil) return;
|
|
setIsToggling(true);
|
|
try {
|
|
await updateCoil(enableCoil.address, newState);
|
|
} catch (error) {
|
|
console.error(`Failed to toggle profile ${profile.slot}`, error);
|
|
} finally {
|
|
setIsToggling(false);
|
|
}
|
|
};
|
|
|
|
// Create a name-to-slaveid map from the partition config
|
|
const controllerNameToSlaveIdMap = React.useMemo(() => {
|
|
const map = new Map<string, number>();
|
|
if (!settings) return map;
|
|
settings.partitions.forEach(partition => {
|
|
partition.controllers?.forEach(controller => {
|
|
if (controller.name) {
|
|
map.set(controller.name, controller.slaveid);
|
|
}
|
|
});
|
|
});
|
|
return map;
|
|
}, [settings]);
|
|
|
|
const getControllerPv = (controllerName: string): number | string => {
|
|
const slaveid = controllerNameToSlaveIdMap.get(controllerName);
|
|
|
|
if (slaveid === undefined) {
|
|
// Fallback or error for controllers not in the static config
|
|
// This might happen if profiles are associated with controllers not in PARTITION_CONFIG
|
|
const fallbackSlaveId = getSlaveIdFromGroup(controllerName);
|
|
if(fallbackSlaveId) {
|
|
// You can decide if you want to support this fallback.
|
|
// For now, let's just log a warning and return N/A if not in the map.
|
|
}
|
|
console.warn(`Could not determine slaveid for controller from settings: ${controllerName}`,controllerNameToSlaveIdMap);
|
|
return 'N/A';
|
|
}
|
|
|
|
const pvRegister = registers.find(
|
|
reg => getSlaveIdFromGroup(reg.group) === slaveid && reg.name.endsWith(PV_REGISTER_NAME_SUFFIX)
|
|
);
|
|
|
|
if (pvRegister && typeof pvRegister.value === 'number') {
|
|
return pvRegister.value.toFixed(1);
|
|
} else {
|
|
// It's possible for the register to exist but the value not be a number yet.
|
|
// Or for the register not to be found immediately.
|
|
console.error(`PV register not found for controller ${controllerName} (slaveid: ${slaveid})`);
|
|
}
|
|
|
|
return 'N/A';
|
|
};
|
|
|
|
useEffect(() => {
|
|
const getPlainTextFromMarkdown = async (markdown: string = '') => {
|
|
return markdown;
|
|
};
|
|
|
|
if (profile.description) {
|
|
getPlainTextFromMarkdown(profile.description);
|
|
} else {
|
|
setPlainTextDescription('');
|
|
}
|
|
}, [profile.description]);
|
|
|
|
return (
|
|
<Card className="w-full flex flex-col glass-card border-0 glass-shimmer hover:shadow-2xl transition-all duration-500">
|
|
<CardHeader className="pb-2">
|
|
<div className="flex justify-between items-start">
|
|
<CardTitle className="text-lg font-semibold accent-text">
|
|
{profile.name}
|
|
</CardTitle>
|
|
<div className="flex items-center space-x-3">
|
|
<div className={`status-indicator ${isEnabled ? 'status-connected' : 'status-disconnected'}`}></div>
|
|
<Switch
|
|
id={`enable-profile-${profile.slot}`}
|
|
checked={isEnabled}
|
|
onCheckedChange={handleToggle}
|
|
disabled={isToggling}
|
|
className="data-[state=checked]:bg-emerald-500"
|
|
/>
|
|
<Label htmlFor={`enable-profile-${profile.slot}`} className="text-xs text-slate-600 dark:text-white/70 font-medium">
|
|
{isEnabled ? <T>Enabled</T> : <T>Disabled</T>}
|
|
</Label>
|
|
</div>
|
|
</div>
|
|
<div className="text-xs text-slate-600 dark:text-white/60 pt-1">
|
|
<div className="grid grid-cols-2 gap-x-4 gap-y-1">
|
|
<div>
|
|
<span className="text-slate-600 dark:text-slate-300">{formatDuration(profile.duration)} <T>Total</T></span>
|
|
</div>
|
|
<div>
|
|
<span className="font-semibold text-indigo-600 dark:text-indigo-300"><T>{getStatusText(profile.status)}</T></span>
|
|
</div>
|
|
<div>
|
|
<span className="text-slate-600 dark:text-slate-300">{profile.max}°C <T>Max</T></span>
|
|
</div>
|
|
{(profile.status === PlotStatus.RUNNING || profile.status === PlotStatus.PAUSED || profile.status === PlotStatus.INITIALIZING) && (
|
|
<>
|
|
{profile.currentTemp !== undefined && (
|
|
<div>
|
|
<span className="font-semibold text-cyan-600 dark:text-cyan-300">{profile.currentTemp}°C <T>Now</T></span>
|
|
</div>
|
|
)}
|
|
{profile.elapsed !== undefined && (
|
|
<div>
|
|
<span className="font-semibold text-emerald-600 dark:text-emerald-300">{formatDuration(profile.elapsed)} <T>Elapsed</T></span>
|
|
</div>
|
|
)}
|
|
{profile.remaining !== undefined && (
|
|
<div>
|
|
<span className="font-semibold text-amber-600 dark:text-amber-300">{formatDuration(profile.remaining)} <T>Remaining</T></span>
|
|
</div>
|
|
)}
|
|
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3 pt-2 flex-grow">
|
|
{plainTextDescription && (
|
|
<p className="text-sm text-slate-600 dark:text-white/60 mb-3">
|
|
{plainTextDescription}
|
|
</p>
|
|
)}
|
|
|
|
{profile.associatedControllerNames && profile.associatedControllerNames.length > 0 && (
|
|
<div className="pt-2 glass-card p-3">
|
|
<p className="text-xs font-medium text-slate-600 dark:text-slate-300 mb-2">{translate("Associated Controllers:")}</p>
|
|
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-xs">
|
|
{profile.associatedControllerNames.map((name, index) => {
|
|
const slaveid = controllerNameToSlaveIdMap.get(name);
|
|
return (
|
|
<div key={index} className="flex justify-between items-center">
|
|
<span className="text-slate-600 dark:text-white/70">{name}{slaveid !== undefined ? ` (${slaveid})` : ''}:</span>
|
|
<span className="font-semibold text-indigo-600 dark:text-indigo-300">{getControllerPv(name)}°C</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex-grow">
|
|
<BezierEditor
|
|
controlPoints={profile.controlPoints}
|
|
onChange={() => {}}
|
|
max={profile.max}
|
|
duration={profile.duration}
|
|
readonly
|
|
showGridLabels={false}
|
|
className="h-40 w-full"
|
|
elapsedTime={profile.elapsed}
|
|
isRunning={profile.status === PlotStatus.RUNNING || profile.status === PlotStatus.PAUSED || profile.status === PlotStatus.INITIALIZING}
|
|
currentTemp={profile.currentTemp}
|
|
/>
|
|
</div>
|
|
|
|
{zones && zones.length > 0 && onApplyToZone && (
|
|
<div className="space-y-2 pt-3 glass-card p-3">
|
|
<p className="text-sm font-medium text-slate-600 dark:text-slate-300">Apply to zone:</p>
|
|
<Select onValueChange={(zoneId) => onApplyToZone(profileId, zoneId)}>
|
|
<SelectTrigger className="w-full glass-input">
|
|
<SelectValue placeholder={<T>Select zone</T>} />
|
|
</SelectTrigger>
|
|
<SelectContent className="glass-panel border-0">
|
|
{zones.map((zone) => (
|
|
<SelectItem key={zone.id} value={zone.id} className="text-slate-700 dark:text-white/90 hover:bg-slate-100 dark:hover:bg-white/10">
|
|
{zone.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
<CardFooter className="flex justify-between gap-2 pt-4">
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
onClick={() => navigate(`/profiles/edit/${profile.slot}`)}
|
|
title={translate("Edit Profile")}
|
|
className="glass-button"
|
|
>
|
|
<Edit className="h-4 w-4" />
|
|
</Button>
|
|
|
|
{(profile.status === PlotStatus.IDLE || profile.status === PlotStatus.FINISHED || profile.status === PlotStatus.STOPPED) && (
|
|
<Button
|
|
className="flex-1 gap-1 status-gradient-connected text-white border-0 hover:shadow-lg transition-all duration-300"
|
|
onClick={() => {
|
|
console.log("start profile", profile.slot);
|
|
onCommand(profile.slot, TemperatureProfileCommand.START);
|
|
}}
|
|
title={translate("Start Profile")}
|
|
disabled={false}
|
|
>
|
|
<Play className="h-4 w-4" />
|
|
<T>Start</T>
|
|
</Button>
|
|
)}
|
|
{(profile.status === PlotStatus.RUNNING || profile.status === PlotStatus.INITIALIZING) && (
|
|
<Button
|
|
className="flex-1 gap-1 bg-gradient-to-r from-amber-400 to-orange-500 text-white border-0 hover:shadow-lg transition-all duration-300"
|
|
onClick={() => onCommand(profile.slot, TemperatureProfileCommand.PAUSE)}
|
|
title={translate("Pause Profile")}
|
|
disabled={profile.status === PlotStatus.INITIALIZING}
|
|
>
|
|
<Pause className="h-4 w-4" />
|
|
<T>Pause</T>
|
|
</Button>
|
|
)}
|
|
{profile.status === PlotStatus.PAUSED && (
|
|
<Button
|
|
className="flex-1 gap-1 bg-gradient-to-r from-cyan-400 to-blue-500 text-white border-0 hover:shadow-lg transition-all duration-300"
|
|
onClick={() => onCommand(profile.slot, TemperatureProfileCommand.RESUME)}
|
|
title={translate("Resume Profile")}
|
|
disabled={false}
|
|
>
|
|
<Play className="h-4 w-4" />
|
|
<T>Resume</T>
|
|
</Button>
|
|
)}
|
|
|
|
{(profile.status === PlotStatus.RUNNING || profile.status === PlotStatus.PAUSED || profile.status === PlotStatus.INITIALIZING) && (
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
className="glass-button border-red-400/50 text-red-300 hover:bg-red-500/20"
|
|
onClick={() => onCommand(profile.slot, TemperatureProfileCommand.STOP)}
|
|
title={translate("Stop Profile")}
|
|
disabled={false}
|
|
>
|
|
<StopCircle className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
onClick={() => onDuplicate(profile)}
|
|
title={translate("Duplicate Profile")}
|
|
className="glass-button border-cyan-400/50 text-cyan-300 hover:bg-cyan-500/20"
|
|
disabled={!canDuplicate}
|
|
>
|
|
<Copy className="h-4 w-4" />
|
|
</Button>
|
|
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
onClick={() => onCopyTo(profile)}
|
|
title={translate("Copy to existing slot...")}
|
|
className="glass-button border-indigo-400/50 text-indigo-300 hover:bg-indigo-500/20"
|
|
>
|
|
<CopyPlus className="h-4 w-4" />
|
|
</Button>
|
|
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
onClick={() => onDelete(profileId)}
|
|
className="glass-button border-red-400/50 text-red-300 hover:bg-red-500/20"
|
|
title={translate("Delete Profile")}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</CardFooter>
|
|
</Card>
|
|
);
|
|
};
|
|
|
|
export default ProfileCard;
|