Upload project Nakula

This commit is contained in:
FaisalRidho12 2026-06-02 13:47:00 +07:00
parent fa36f6d4f3
commit 9dd48cce28
24 changed files with 3464 additions and 641 deletions

51
App.tsx
View File

@ -1,45 +1,16 @@
/**
* Sample React Native App
* https://github.com/facebook/react-native
*
* @format
*/
import { NewAppScreen } from '@react-native/new-app-screen';
import { StatusBar, StyleSheet, useColorScheme, View } from 'react-native';
import {
SafeAreaProvider,
useSafeAreaInsets,
} from 'react-native-safe-area-context';
function App() {
const isDarkMode = useColorScheme() === 'dark';
import React from "react";
import { StatusBar } from "react-native";
import { NavigationContainer } from "@react-navigation/native";
import { SafeAreaProvider } from "react-native-safe-area-context";
import BottomTabNavigator from "./src/navigation/BottomTabNavigator";
export default function App() {
return (
<SafeAreaProvider>
<StatusBar barStyle={isDarkMode ? 'light-content' : 'dark-content'} />
<AppContent />
<NavigationContainer>
<StatusBar barStyle="dark-content" backgroundColor="#f8fafc" />
<BottomTabNavigator />
</NavigationContainer>
</SafeAreaProvider>
);
}
function AppContent() {
const safeAreaInsets = useSafeAreaInsets();
return (
<View style={styles.container}>
<NewAppScreen
templateFileName="App.tsx"
safeAreaInsets={safeAreaInsets}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
});
export default App;
}

View File

@ -1,6 +1,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<application
android:name=".MainApplication"
@ -9,7 +11,7 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:allowBackup="false"
android:theme="@style/AppTheme"
android:usesCleartextTraffic="${usesCleartextTraffic}"
android:usesCleartextTraffic="true"
android:supportsRtl="true">
<activity
android:name=".MainActivity"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 34 KiB

View File

@ -4,7 +4,7 @@ buildscript {
minSdkVersion = 24
compileSdkVersion = 36
targetSdkVersion = 36
ndkVersion = "27.1.12297006"
ndkVersion = "27.2.12479018"
kotlinVersion = "2.1.20"
}
repositories {

View File

@ -1,4 +1,7 @@
{
"name": "Nakula",
"displayName": "Nakula"
"displayName": "Nakula",
"plugins": [
"expo-sharing"
]
}

BIN
assets/control.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
assets/dashboard.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

BIN
assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

BIN
assets/report.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

1933
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -10,10 +10,26 @@
"test": "jest"
},
"dependencies": {
"@react-native-community/datetimepicker": "^9.1.0",
"@react-native-community/slider": "^5.1.2",
"@react-native/new-app-screen": "0.84.1",
"@react-navigation/bottom-tabs": "^7.15.2",
"@react-navigation/native": "^7.1.31",
"@react-navigation/native-stack": "^7.14.2",
"axios": "^1.15.1",
"react": "19.2.3",
"react-native": "0.84.1",
"@react-native/new-app-screen": "0.84.1",
"react-native-safe-area-context": "^5.5.2"
"react-native-chart-kit": "^6.12.0",
"react-native-fs": "^2.20.0",
"react-native-gesture-handler": "^2.30.0",
"react-native-html-to-pdf": "^1.3.0",
"react-native-print": "^0.11.0",
"react-native-reanimated": "^4.2.2",
"react-native-safe-area-context": "^5.7.0",
"react-native-screens": "^4.24.0",
"react-native-share": "^12.2.6",
"react-native-svg": "^15.15.3",
"react-native-worklets": "^0.8.1"
},
"devDependencies": {
"@babel/core": "^7.25.2",
@ -32,10 +48,11 @@
"eslint": "^8.19.0",
"jest": "^29.6.3",
"prettier": "2.8.8",
"react-native-make": "^1.0.1",
"react-test-renderer": "19.2.3",
"typescript": "^5.8.3"
},
"engines": {
"node": ">= 22.11.0"
}
}
}

209
src/components/BarChart.tsx Normal file
View File

@ -0,0 +1,209 @@
import React from "react";
import { View, Text, StyleSheet } from "react-native";
import { HistoryPoint } from "../services/api";
interface BarChartProps {
history: HistoryPoint[];
}
const GRADE_COLORS = {
A: "#16a34a",
B: "#d97706",
C: "#dc2626",
TL: "#6b7280",
};
export default function BarChart({ history }: BarChartProps) {
if (!history?.length) return null;
const show = history.slice(-5);
const maxVal = Math.max(...show.flatMap((h) => [h.A, h.B, h.C, h.TL ?? 0]), 1);
const rawMax = Math.max(...show.flatMap((h) => [h.A, h.B, h.C, h.TL ?? 0]), 1);
const niceMax = Math.ceil(rawMax / 10) * 10;
const ySteps = [
niceMax,
niceMax * 0.75,
niceMax * 0.5,
niceMax * 0.25,
0,
];
return (
<View style={styles.container}>
{/* Y Vertical */}
<View style={styles.yAxisRow}>
<View style={styles.yAxis}>
{/* {[100, 75, 50, 25, 0].map((v) => ( */}
{ySteps.map((v, i) => (
// <Text key={v} style={styles.yLabel}>{v}</Text>
<Text key={i} style={styles.yLabel}>{Math.round(v)}</Text>
))}
</View>
{/* Chart area */}
<View style={styles.chartArea}>
{ySteps.map((_, i) => (
<View
key={i}
style={[
styles.gridLine,
{
bottom: `${(i / (ySteps.length - 1)) * 100}%`,
},
]}
/>
))}
{/* Bars */}
<View style={styles.chartRow}>
{show.map((item, i) => (
<View key={i} style={styles.barGroup}>
<View style={styles.barsContainer}>
{(["A", "B", "C", "TL"] as const).map((g) => {
const val = item[g] ?? 0;
// const heightPct = Math.max(2, (val / maxVal) * 100);
const heightPct = Math.max(2, (val / niceMax) * 100);
return (
<View key={g} style={styles.barWrapper}>
<Text style={[styles.barValue, { color: GRADE_COLORS[g] }]}>
{val > 0 ? val : ""}
</Text>
<View
style={[
styles.bar,
{
height: (heightPct / 100) * 160,
backgroundColor: GRADE_COLORS[g],
},
]}
/>
</View>
);
})}
</View>
<Text style={styles.timeLabel}>{item.time}</Text>
</View>
))}
</View>
</View>
</View>
{/* Legend */}
<View style={styles.legend}>
{(["A", "B", "C", "TL"] as const).map((g) => (
<View key={g} style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: GRADE_COLORS[g] }]} />
<Text style={styles.legendText}>
{g === "TL" ? "Tidak Layak" : `Grade ${g}`}
</Text>
</View>
))}
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
paddingHorizontal: 8,
paddingTop: 8,
},
/* Y-axis + chart side-by-side */
yAxisRow: {
flexDirection: "row",
alignItems: "flex-end",
},
yAxis: {
width: 28,
height: 180, // matches chartArea height
justifyContent: "space-between",
alignItems: "flex-end",
paddingRight: 4,
paddingBottom: 22, // reserve space for time labels
},
yLabel: {
fontSize: 9,
color: "#94a3b8",
},
/* Chart area */
chartArea: {
flex: 1,
height: 180,
position: "relative",
justifyContent: "flex-end",
},
gridLine: {
position: "absolute",
left: 0,
right: 0,
height: 1,
backgroundColor: "#e2e8f0",
opacity: 0.6,
},
/* Bar row */
chartRow: {
flexDirection: "row",
alignItems: "flex-end",
height: 160,
gap: 6,
paddingHorizontal: 2,
},
barGroup: {
flex: 1,
alignItems: "center",
},
barsContainer: {
flexDirection: "row",
alignItems: "flex-end",
gap: 2,
height: 160,
},
barWrapper: {
alignItems: "center",
justifyContent: "flex-end",
},
barValue: {
fontSize: 8,
fontWeight: "600",
marginBottom: 1,
},
bar: {
width: 10,
borderRadius: 3,
},
timeLabel: {
fontSize: 9,
color: "#64748b",
marginTop: 5,
textAlign: "center",
},
/* Legend */
legend: {
flexDirection: "row",
justifyContent: "center",
flexWrap: "wrap",
gap: 14,
marginTop: 14,
paddingBottom: 4,
},
legendItem: {
flexDirection: "row",
alignItems: "center",
gap: 5,
},
legendDot: {
width: 10,
height: 10,
borderRadius: 3,
},
legendText: {
fontSize: 12,
color: "#475569",
fontWeight: "500",
},
});

View File

@ -0,0 +1,237 @@
import React, { useState } from "react";
import {
View,
Text,
Modal,
TouchableOpacity,
StyleSheet,
} from "react-native";
// ─── Helper ───────────────────────────────────────────────────────────────────
const BULAN = [
"Januari","Februari","Maret","April","Mei","Juni",
"Juli","Agustus","September","Oktober","November","Desember",
];
const HARI = ["Min","Sen","Sel","Rab","Kam","Jum","Sab"];
export const toISO = (d: Date) =>
`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`;
export const fromISO = (s: string): Date => {
const [y,m,d] = s.split("-").map(Number);
return new Date(y, m-1, d);
};
export const toDisplay = (s: string): string => {
const d = fromISO(s);
return `${String(d.getDate()).padStart(2,"0")}/${String(d.getMonth()+1).padStart(2,"0")}/${d.getFullYear()}`;
};
function getDaysInMonth(year: number, month: number) {
return new Date(year, month + 1, 0).getDate();
}
// ─── Props ────────────────────────────────────────────────────────────────────
interface CalendarPickerProps {
visible: boolean;
value: string; // "yyyy-mm-dd"
minDate?: string;
maxDate?: string;
title?: string;
onConfirm: (date: string) => void;
onClose: () => void;
}
// ─── Komponen ─────────────────────────────────────────────────────────────────
export default function CalendarPicker({
visible, value, minDate, maxDate, title = "Pilih Tanggal",
onConfirm, onClose,
}: CalendarPickerProps) {
const initDate = fromISO(value);
const [viewYear, setViewYear] = useState(initDate.getFullYear());
const [viewMonth, setViewMonth] = useState(initDate.getMonth());
const [selected, setSelected] = useState(value);
const todayISO = toISO(new Date());
const totalDays = getDaysInMonth(viewYear, viewMonth);
const firstDay = new Date(viewYear, viewMonth, 1).getDay(); // 0=Minggu
// Navigasi bulan
const prevMonth = () => {
if (viewMonth === 0) { setViewMonth(11); setViewYear(y => y - 1); }
else setViewMonth(m => m - 1);
};
const nextMonth = () => {
if (viewMonth === 11) { setViewMonth(0); setViewYear(y => y + 1); }
else setViewMonth(m => m + 1);
};
const isDisabled = (iso: string) => {
if (minDate && iso < minDate) return true;
if (maxDate && iso > maxDate) return true;
return false;
};
const isToday = (iso: string) => iso === todayISO;
const isSelected = (iso: string) => iso === selected;
// Buat grid hari (null = sel kosong sebelum hari pertama)
const cells: (number | null)[] = [
...Array(firstDay).fill(null),
...Array.from({ length: totalDays }, (_, i) => i + 1),
];
// Lengkapi sampai kelipatan 7
while (cells.length % 7 !== 0) cells.push(null);
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<TouchableOpacity style={s.overlay} activeOpacity={1} onPress={onClose}>
<TouchableOpacity activeOpacity={1} style={s.card} onPress={() => {}}>
{/* Judul */}
<Text style={s.title}>{title}</Text>
{/* Nav bulan */}
<View style={s.navRow}>
<TouchableOpacity onPress={prevMonth} style={s.navBtn}>
<Text style={s.navArrow}></Text>
</TouchableOpacity>
<Text style={s.navLabel}>
{BULAN[viewMonth]} {viewYear}
</Text>
<TouchableOpacity onPress={nextMonth} style={s.navBtn}>
<Text style={s.navArrow}></Text>
</TouchableOpacity>
</View>
{/* Header hari */}
<View style={s.weekRow}>
{HARI.map(h => (
<Text key={h} style={[s.weekCell, h === "Min" && { color: "#dc2626" }]}>{h}</Text>
))}
</View>
{/* Grid tanggal */}
<View style={s.grid}>
{cells.map((day, i) => {
if (!day) return <View key={`e${i}`} style={s.dayCell} />;
const iso = `${viewYear}-${String(viewMonth+1).padStart(2,"0")}-${String(day).padStart(2,"0")}`;
const disabled = isDisabled(iso);
const today = isToday(iso);
const sel = isSelected(iso);
const isSun = (firstDay + day - 1) % 7 === 0;
return (
<TouchableOpacity
key={iso}
disabled={disabled}
onPress={() => setSelected(iso)}
style={[
s.dayCell,
sel && s.daySel,
today && !sel && s.dayToday,
]}
>
<Text style={[
s.dayText,
isSun && !sel && { color: "#dc2626" },
today && !sel && { color: "#4f46e5", fontWeight: "700" },
sel && { color: "#fff", fontWeight: "800" },
disabled && { color: "#d1d5db" },
]}>
{day}
</Text>
{today && !sel && <View style={s.todayDot} />}
</TouchableOpacity>
);
})}
</View>
{/* Preview tanggal dipilih */}
<View style={s.previewRow}>
<Text style={s.previewText}>
Dipilih:{" "}
<Text style={s.previewVal}>{toDisplay(selected)}</Text>
</Text>
</View>
{/* Tombol aksi */}
<View style={s.btnRow}>
<TouchableOpacity onPress={onClose} style={s.btnCancel}>
<Text style={s.btnCancelText}>Batal</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => { onConfirm(selected); onClose(); }} style={s.btnOk}>
<Text style={s.btnOkText}>Pilih</Text>
</TouchableOpacity>
</View>
</TouchableOpacity>
</TouchableOpacity>
</Modal>
);
}
// ─── Styles ───────────────────────────────────────────────────────────────────
const CELL_SIZE = 38;
const s = StyleSheet.create({
overlay: {
flex: 1, backgroundColor: "rgba(0,0,0,0.45)",
justifyContent: "center", alignItems: "center",
},
card: {
backgroundColor: "#fff", borderRadius: 20, padding: 20,
width: 320,
shadowColor: "#000", shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.18, shadowRadius: 20, elevation: 16,
},
title: {
fontSize: 16, fontWeight: "800", color: "#0f172a",
textAlign: "center", marginBottom: 16,
},
// Nav
navRow: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", marginBottom: 12 },
navBtn: { width: 36, height: 36, borderRadius: 18, backgroundColor: "#f1f5f9", alignItems: "center", justifyContent: "center" },
navArrow: { fontSize: 22, color: "#4f46e5", lineHeight: 26 },
navLabel: { fontSize: 15, fontWeight: "700", color: "#0f172a" },
// Header hari
weekRow: { flexDirection: "row", marginBottom: 6 },
weekCell: { width: CELL_SIZE, textAlign: "center", fontSize: 11, fontWeight: "600", color: "#94a3b8" },
// Grid
grid: { flexDirection: "row", flexWrap: "wrap" },
dayCell: {
width: CELL_SIZE, height: CELL_SIZE,
alignItems: "center", justifyContent: "center",
borderRadius: CELL_SIZE / 2, marginBottom: 2,
},
daySel: { backgroundColor: "#4f46e5" },
dayToday: { borderWidth: 1.5, borderColor: "#4f46e5" },
dayText: { fontSize: 13, color: "#334155" },
todayDot: {
position: "absolute", bottom: 4,
width: 4, height: 4, borderRadius: 2, backgroundColor: "#4f46e5",
},
// Preview
previewRow: {
backgroundColor: "#f8fafc", borderRadius: 10, padding: 10,
marginTop: 12, alignItems: "center",
},
previewText: { fontSize: 12, color: "#64748b" },
previewVal: { fontWeight: "700", color: "#0f172a" },
// Buttons
btnRow: { flexDirection: "row", gap: 10, marginTop: 14 },
btnCancel: { flex: 1, paddingVertical: 12, borderRadius: 12, backgroundColor: "#f1f5f9", alignItems: "center" },
btnCancelText:{ fontSize: 14, color: "#64748b", fontWeight: "600" },
btnOk: { flex: 2, paddingVertical: 12, borderRadius: 12, backgroundColor: "#4f46e5", alignItems: "center" },
btnOkText: { fontSize: 14, color: "#fff", fontWeight: "800" },
});

View File

@ -0,0 +1,162 @@
import React, { useRef } from "react";
import {
View,
Text,
StyleSheet,
PanResponder,
GestureResponderEvent,
} from "react-native";
interface CylinderSliderProps {
value: number;
max: number;
onChange: (value: number) => void;
color: string;
label: string;
}
const CYLINDER_HEIGHT = 180;
export default function CylinderSlider({
value,
max,
onChange,
color,
label,
}: CylinderSliderProps) {
const pct = value / max;
const fillHeight = pct * CYLINDER_HEIGHT;
// Simpan pageY saat touch mulai dan nilai saat itu
const startPageY = useRef(0);
const startValue = useRef(value);
const clamp = (v: number) => Math.max(0, Math.min(max, Math.round(v)));
const panResponder = PanResponder.create({
onStartShouldSetPanResponder: () => true,
onMoveShouldSetPanResponder: () => true,
onPanResponderGrant: (e: GestureResponderEvent) => {
// Catat posisi pageY dan nilai awal saat jari menyentuh
startPageY.current = e.nativeEvent.pageY;
startValue.current = value;
},
onPanResponderMove: (e: GestureResponderEvent) => {
// Hitung delta dari posisi awal (geser ke atas = naik, ke bawah = turun)
const deltaY = e.nativeEvent.pageY - startPageY.current;
// deltaY negatif = geser ke atas = nilai naik
const deltaPct = -deltaY / CYLINDER_HEIGHT;
const newVal = clamp(startValue.current + deltaPct * max);
onChange(newVal);
},
});
return (
<View style={styles.container}>
<Text style={styles.label}>{label}</Text>
<View
{...panResponder.panHandlers}
style={[
styles.cylinder,
{ borderColor: color + "66", shadowColor: color },
]}
>
{/* Fill dari bawah */}
<View style={[styles.fill, { height: fillHeight, backgroundColor: color }]} />
{/* Shine */}
<View style={styles.shine} />
{/* Tick marks */}
{[0.25, 0.5, 0.75].map((t) => (
<View
key={t}
style={[styles.tick, { top: (1 - t) * CYLINDER_HEIGHT }]}
/>
))}
{/* Label persentase di tengah cylinder */}
<View style={styles.pctOverlay}>
<Text style={[styles.pctText, { color: pct > 0.45 ? "#fff" : color }]}>
{Math.round(pct * 100)}%
</Text>
</View>
</View>
<Text style={[styles.value, { color }]}>{value}</Text>
</View>
);
}
const styles = StyleSheet.create({
container: { alignItems: "center", gap: 10 },
label: {
fontSize: 10,
fontFamily: "monospace",
letterSpacing: 0.5,
color: "#94a3b8",
textAlign: "center",
},
cylinder: {
width: 56,
height: CYLINDER_HEIGHT,
borderRadius: 28,
backgroundColor: "#f1f5f9",
borderWidth: 2,
overflow: "hidden",
justifyContent: "flex-end",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.2,
shadowRadius: 8,
elevation: 4,
},
fill: {
position: "absolute",
bottom: 0,
left: 0,
right: 0,
borderRadius: 26,
opacity: 0.85,
},
shine: {
position: "absolute",
top: 0,
right: 0,
bottom: 0,
width: 14,
backgroundColor: "rgba(255,255,255,0.5)",
},
tick: {
position: "absolute",
left: 10,
right: 10,
height: 1,
backgroundColor: "rgba(0,0,0,0.08)",
},
pctOverlay: {
position: "absolute",
top: 0, bottom: 0, left: 0, right: 0,
justifyContent: "center",
alignItems: "center",
},
pctText: {
fontSize: 12,
fontWeight: "800",
fontFamily: "monospace",
},
value: {
fontSize: 18,
fontWeight: "800",
fontFamily: "monospace",
},
});

View File

@ -0,0 +1,43 @@
import React from "react";
import Svg, { Polyline } from "react-native-svg";
interface SparklineProps {
data: number[];
color: string;
width?: number;
height?: number;
}
export default function Sparkline({
data,
color,
width = 80,
height = 30,
}: SparklineProps) {
if (!data || data.length < 2) return null;
const max = Math.max(...data);
const min = Math.min(...data);
const range = max - min || 1;
const points = data
.map((v, i) => {
const x = (i / (data.length - 1)) * width;
const y = height - ((v - min) / range) * height;
return `${x},${y}`;
})
.join(" ");
return (
<Svg width={width} height={height}>
<Polyline
points={points}
fill="none"
stroke={color}
strokeWidth="2"
strokeLinejoin="round"
strokeLinecap="round"
/>
</Svg>
);
}

View File

@ -0,0 +1,105 @@
import React from "react";
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
import { View, Text, StyleSheet, Image } from "react-native";
import DashboardScreen from "../screens/DashboardScreen";
import LaporanScreen from "../screens/LaporanScreen";
import ControlScreen from "../screens/ControlScreen";
function TabIcon({
focused,
icon,
color,
}: {
focused: boolean;
icon: any;
color: string;
}) {
return (
<View
style={[
styles.iconContainer,
focused && { transform: [{ scale: 1.15 }] },
]}
>
<Image
source={icon}
style={{
width: 22,
height: 22,
tintColor: color,
resizeMode: "contain",
}}
/>
</View>
);
}
const Tab = createBottomTabNavigator();
const TAB_CONFIG = {
Dashboard: {
icon: require("../../assets/dashboard.png"),
color: "#4f46e5",
},
Laporan: {
icon: require("../../assets/report.png"),
color: "#16a34a",
},
Kontrol: {
icon: require("../../assets/control.png"),
color: "#0284c7",
},
};
export default function BottomTabNavigator() {
return (
<Tab.Navigator
screenOptions={({ route }) => {
const config = TAB_CONFIG[route.name as keyof typeof TAB_CONFIG];
return {
headerShown: false,
tabBarStyle: styles.tabBar,
tabBarActiveTintColor: config.color,
tabBarInactiveTintColor: "#94a3b8",
tabBarLabelStyle: styles.tabLabel,
tabBarIcon: ({ focused, color }) => (
<TabIcon focused={focused} icon={config.icon} color={color} />
),
tabBarItemStyle: styles.tabItem,
};
}}
>
<Tab.Screen name="Dashboard" component={DashboardScreen} />
<Tab.Screen name="Laporan" component={LaporanScreen} />
<Tab.Screen name="Kontrol" component={ControlScreen} />
</Tab.Navigator>
);
}
const styles = StyleSheet.create({
tabBar: {
backgroundColor: "#fff",
borderTopColor: "#e2e8f0",
borderTopWidth: 1,
height: 72,
paddingBottom: 10,
paddingTop: 6,
shadowColor: "#000",
shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.05,
shadowRadius: 8,
elevation: 8,
},
tabItem: {
paddingTop: 4,
},
tabLabel: {
fontSize: 10,
fontWeight: "600",
marginTop: 2,
},
iconContainer: {
alignItems: "center",
justifyContent: "center",
},
});

View File

@ -0,0 +1,401 @@
import React, { useState, useEffect, useCallback } from "react";
import {
View,
Text,
ScrollView,
StyleSheet,
TouchableOpacity,
TextInput,
Alert,
ActivityIndicator,
Keyboard,
} from "react-native";
import { useFocusEffect } from "@react-navigation/native";
import Slider from "@react-native-community/slider";
import CylinderSlider from "../components/CylinderSlinder";
import { setConveyorSpeed, getConveyorSpeed } from "../services/api";
// ─── Konstanta ────────────────────────────────────────────────────────────────
const SPEED_MODES = [
{ key: "pelan", label: "Pelan", value: 50, desc: "Cocok untuk telur rapuh" },
{ key: "sedang", label: "Sedang", value: 128, desc: "Mode normal" },
{ key: "cepat", label: "Cepat", value: 220, desc: "Throughput tinggi" },
];
function getSpeedLabel(v: number) {
if (v < 80) return "Pelan";
if (v < 170) return "Sedang";
return "Cepat";
}
function getSpeedColor(v: number) {
if (v < 80) return "#16a34a";
if (v < 170) return "#d97706";
return "#dc2626";
}
// ─── Komponen utama ───────────────────────────────────────────────────────────
export default function ControlScreen() {
const [speed, setSpeed] = useState(128);
const [speedMode, setSpeedMode] = useState("sedang");
const [speedInput, setSpeedInput] = useState("128");
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [loadingCurrent, setLoadingCurrent] = useState(true);
const [refreshing, setRefreshing] = useState(false);
// ── Fetch speed dari server ───────────────────────────────────────────────
const fetchSpeed = useCallback(async (isManual = false) => {
if (isManual) setRefreshing(true);
else setLoadingCurrent(true);
try {
const currentSpeed = await getConveyorSpeed();
setSpeed(currentSpeed);
setSpeedInput(String(currentSpeed));
const match = SPEED_MODES.find((m) => m.value === currentSpeed);
setSpeedMode(match ? match.key : "");
} catch {
if (isManual) {
Alert.alert("Gagal", "Tidak dapat mengambil data dari server.");
}
} finally {
setLoadingCurrent(false);
setRefreshing(false);
}
}, []);
// Fetch pertama kali saat app buka
useEffect(() => { fetchSpeed(); }, []);
// ✅ Fetch ulang SETIAP KALI tab Control difokuskan (balik dari tab lain)
// Ini memastikan data selalu sinkron dengan server, bukan dari state lama
useFocusEffect(
useCallback(() => {
fetchSpeed();
}, [fetchSpeed])
);
// ── Handler ───────────────────────────────────────────────────────────────
const applySpeedMode = (key: string, value: number) => {
setSpeedMode(key);
setSpeed(value);
setSpeedInput(String(value));
};
// Dipanggil dari cylinder atau slider — TIDAK dari input teks
const handleSpeedChange = (v: number) => {
const rounded = Math.round(v);
setSpeed(rounded);
setSpeedInput(String(rounded));
const match = SPEED_MODES.find((m) => m.value === rounded);
setSpeedMode(match ? match.key : "");
};
/**
* Terapkan nilai dari input teks.
* HANYA dipanggil saat tombol "Terapkan" diklik BUKAN saat blur/dismiss keyboard.
*/
const handleApplyInput = () => {
Keyboard.dismiss();
const val = Math.max(0, Math.min(255, parseInt(speedInput, 10) || 0));
setSpeed(val);
setSpeedInput(String(val));
const match = SPEED_MODES.find((m) => m.value === val);
setSpeedMode(match ? match.key : "");
};
const handleSave = async () => {
try {
setSaving(true);
await setConveyorSpeed(speed);
setSaved(true);
setTimeout(() => setSaved(false), 2500);
} catch {
Alert.alert("Error", "Gagal mengirim data ke perangkat. Cek koneksi server.");
} finally {
setSaving(false);
}
};
const activeColor = getSpeedColor(speed);
// ── Loading state ─────────────────────────────────────────────────────────
if (loadingCurrent) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator color="#0284c7" size="large" />
<Text style={styles.loadingText}>Memuat konfigurasi...</Text>
</View>
);
}
// ── Render ────────────────────────────────────────────────────────────────
return (
<ScrollView
style={styles.container}
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled" // ← tap di luar input tidak dismiss & terapkan
>
{/* Header */}
<View style={styles.header}>
<View>
<Text style={styles.title}>Kontrol Conveyor</Text>
<Text style={styles.subtitle}>Atur kecepatan laju conveyor</Text>
</View>
<TouchableOpacity
onPress={() => fetchSpeed(true)}
disabled={refreshing}
style={[styles.refreshBtn, refreshing && { opacity: 0.5 }]}
>
{refreshing
? <ActivityIndicator color="#0284c7" size="small" />
: <Text style={styles.refreshIcon}></Text>
}
</TouchableOpacity>
</View>
{/* Cylinder */}
<View style={styles.cylinderCard}>
<View style={styles.cylinderWrapper}>
<CylinderSlider
value={speed}
max={255}
onChange={handleSpeedChange}
color={activeColor}
label="CONVEYOR SPEED"
/>
<View style={styles.cylinderInfo}>
<Text style={styles.cylinderPct}>
{Math.round((speed / 255) * 100)}%
</Text>
<Text style={styles.cylinderHint}>Geser ke atas / bawah untuk adjust</Text>
</View>
</View>
</View>
{/* Control Card */}
<View style={[styles.controlCard, { borderColor: activeColor + "44" }]}>
{/* Header nilai */}
<View style={styles.controlHeaderRow}>
<View>
<Text style={[styles.controlLabel, { color: activeColor }]}>
Kecepatan Conveyor
</Text>
<View style={styles.valueRow}>
<Text style={[styles.controlValueText, { color: "#1e293b" }]}>{speed}</Text>
<Text style={styles.controlValueUnit}> / 255</Text>
</View>
</View>
<View style={[
styles.modeBadge,
{ backgroundColor: activeColor + "15", borderColor: activeColor + "44" }
]}>
<Text style={[styles.modeBadgeText, { color: activeColor }]}>
{getSpeedLabel(speed)}
</Text>
</View>
</View>
{/* Mode preset */}
<Text style={styles.sectionLabel}>Mode Cepat</Text>
<View style={styles.modeRow}>
{SPEED_MODES.map((m) => {
const active = speedMode === m.key;
const mColor = getSpeedColor(m.value);
return (
<TouchableOpacity
key={m.key}
onPress={() => applySpeedMode(m.key, m.value)}
style={[
styles.modeButton,
{
borderColor: active ? mColor : "#e2e8f0",
backgroundColor: active ? mColor + "15" : "#f8fafc",
},
]}
>
<Text style={[styles.modeButtonLabel, { color: active ? mColor : "#64748b" }]}>
{m.label}
</Text>
<Text style={styles.modeButtonDesc}>{m.desc}</Text>
{active && (
<View style={[styles.activeIndicator, { backgroundColor: mColor }]} />
)}
</TouchableOpacity>
);
})}
</View>
{/* Slider */}
<Text style={styles.sectionLabel}>Slider Manual</Text>
<View style={styles.sliderRow}>
<Text style={styles.sliderMin}>0</Text>
<Slider
style={styles.slider}
minimumValue={0}
maximumValue={255}
step={1}
value={speed}
onValueChange={handleSpeedChange}
minimumTrackTintColor={activeColor}
maximumTrackTintColor="#e2e8f0"
thumbTintColor={activeColor}
/>
<Text style={styles.sliderMax}>255</Text>
</View>
{/* Input angka — onBlur DIHAPUS, hanya terapkan via tombol */}
<Text style={styles.sectionLabel}>Input Angka (0255)</Text>
<View style={styles.inputRow}>
<TextInput
value={speedInput}
onChangeText={setSpeedInput}
// ✅ onBlur TIDAK ADA — supaya dismiss keyboard tidak langsung terapkan
// ✅ onSubmitEditing TIDAK ADA — supaya tekan Enter tidak langsung terapkan
keyboardType="numeric"
maxLength={3}
returnKeyType="done"
style={[styles.numberInput, { borderColor: activeColor + "66" }]}
/>
{/* Tombol Terapkan — SATU-SATUNYA cara nilai input diterapkan */}
<TouchableOpacity
onPress={handleApplyInput}
style={[
styles.applyButton,
{ backgroundColor: activeColor + "15", borderColor: activeColor + "44" },
]}
>
<Text style={[styles.applyButtonText, { color: activeColor }]}>Terapkan</Text>
</TouchableOpacity>
</View>
</View>
{/* Speed bar indikator */}
<View style={styles.speedBar}>
<View style={[
styles.speedBarFill,
{ width: `${(speed / 255) * 100}%` as any, backgroundColor: activeColor },
]} />
<Text style={styles.speedBarLabel}>
{speed < 80
? "⚠️ Kecepatan rendah — pastikan conveyor bergerak"
: speed > 200
? "⚠️ Kecepatan tinggi — awasi kondisi telur"
: "✓ Kecepatan normal"}
</Text>
</View>
{/* Tombol kirim */}
<TouchableOpacity
onPress={handleSave}
disabled={saving}
style={[
styles.saveButton,
{ backgroundColor: saved ? "#16a34a" : "#0284c7", opacity: saving ? 0.7 : 1 },
]}
>
<Text style={styles.saveButtonText}>
{saving ? "Mengirim..." : saved ? "✓ Kecepatan Tersimpan!" : "Kirim ke Perangkat"}
</Text>
</TouchableOpacity>
</ScrollView>
);
}
// ─── Styles ───────────────────────────────────────────────────────────────────
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: "#f8fafc" },
content: { padding: 16, paddingBottom: 32 },
loadingContainer: { flex: 1, justifyContent: "center", alignItems: "center", backgroundColor: "#f8fafc", gap: 12 },
loadingText: { fontSize: 13, color: "#94a3b8" },
header: { flexDirection: "row", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 20 },
title: { fontSize: 24, fontWeight: "800", color: "#0f172a", letterSpacing: -0.5 },
subtitle: { fontSize: 12, color: "#94a3b8", marginTop: 2 },
refreshBtn: {
width: 40, height: 40, borderRadius: 20,
backgroundColor: "#fff", borderWidth: 1, borderColor: "#e2e8f0",
alignItems: "center", justifyContent: "center",
shadowColor: "#000", shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05, shadowRadius: 3, elevation: 2,
},
refreshIcon: { fontSize: 22, color: "#0284c7", fontWeight: "700", lineHeight: 26 },
// Cylinder card
cylinderCard: {
backgroundColor: "#fff", borderWidth: 1, borderColor: "#e2e8f0",
borderRadius: 16, paddingVertical: 28, paddingHorizontal: 16,
marginBottom: 16, alignItems: "center",
shadowColor: "#000", shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05, shadowRadius: 4, elevation: 2,
},
cylinderWrapper: { alignItems: "center", gap: 16 },
cylinderInfo: { alignItems: "center" },
cylinderPct: { fontSize: 32, fontWeight: "800", color: "#0f172a", fontFamily: "monospace" },
cylinderHint: { fontSize: 11, color: "#94a3b8", marginTop: 2 },
// Control card
controlCard: {
backgroundColor: "#fff", borderWidth: 1, borderRadius: 16,
padding: 16, marginBottom: 12,
shadowColor: "#000", shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.04, shadowRadius: 3, elevation: 1,
},
controlHeaderRow: {
flexDirection: "row", justifyContent: "space-between",
alignItems: "flex-start", marginBottom: 16,
},
controlLabel: { fontSize: 12, fontWeight: "700", marginBottom: 4 },
valueRow: { flexDirection: "row", alignItems: "baseline" },
controlValueText: { fontSize: 32, fontWeight: "800", fontFamily: "monospace" },
controlValueUnit: { fontSize: 13, color: "#94a3b8" },
modeBadge: { paddingHorizontal: 12, paddingVertical: 5, borderRadius: 20, borderWidth: 1 },
modeBadgeText: { fontSize: 11, fontWeight: "600" },
sectionLabel: { fontSize: 10, color: "#94a3b8", fontWeight: "600", marginBottom: 8, marginTop: 4 },
// Mode buttons
modeRow: { flexDirection: "row", gap: 8, marginBottom: 16 },
modeButton: { flex: 1, paddingVertical: 10, paddingHorizontal: 6, borderRadius: 12, borderWidth: 1, alignItems: "center", gap: 3, position: "relative" },
modeButtonLabel:{ fontSize: 11, fontWeight: "700" },
modeButtonDesc: { fontSize: 9, color: "#94a3b8", textAlign: "center" },
activeIndicator:{ position: "absolute", top: 6, right: 6, width: 6, height: 6, borderRadius: 3 },
// Slider
sliderRow: { flexDirection: "row", alignItems: "center", gap: 8, marginBottom: 8 },
sliderMin: { fontSize: 10, color: "#94a3b8", width: 16, textAlign: "center" },
sliderMax: { fontSize: 10, color: "#94a3b8", width: 24, textAlign: "center" },
slider: { flex: 1, height: 40 },
// Input angka
inputRow: { flexDirection: "row", gap: 10, alignItems: "center" },
numberInput: {
width: 80, backgroundColor: "#f8fafc", borderWidth: 1.5,
borderRadius: 10, paddingHorizontal: 10, paddingVertical: 9,
color: "#0f172a", fontSize: 18, fontFamily: "monospace",
textAlign: "center", fontWeight: "700",
},
applyButton: { flex: 1, paddingVertical: 12, borderRadius: 10, borderWidth: 1, alignItems: "center" },
applyButtonText: { fontSize: 13, fontWeight: "700" },
inputHint: { fontSize: 10, color: "#cbd5e1", marginTop: 6, fontStyle: "italic" },
// Speed bar
speedBar: {
backgroundColor: "#f1f5f9", borderRadius: 12, overflow: "hidden",
marginBottom: 16, height: 40, justifyContent: "center",
borderWidth: 1, borderColor: "#e2e8f0",
},
speedBarFill: { position: "absolute", top: 0, left: 0, bottom: 0, opacity: 0.15, borderRadius: 12 },
speedBarLabel: { fontSize: 11, color: "#64748b", paddingHorizontal: 12 },
// Save button
saveButton: { borderRadius: 14, paddingVertical: 16, alignItems: "center" },
saveButtonText: { color: "#fff", fontSize: 15, fontWeight: "800", letterSpacing: 0.3 },
});

View File

@ -0,0 +1,391 @@
import React, { useEffect, useState } from "react";
import {
View, Text, ScrollView, StyleSheet,
ActivityIndicator, TouchableOpacity,
} from "react-native";
import { getEggData, EggRecord, EggGrade } from "../services/api";
import Sparkline from "../components/Sparkline";
import BarChart from "../components/BarChart";
import CalendarPicker, { toISO, fromISO, toDisplay } from "../components/CalendarPicker";
import { HistoryPoint } from "../services/api";
const GRADE_CONFIG = {
A: { color:"#16a34a", bg:"#f0fdf4", borderColor:"#bbf7d0", label:"Grade A", range:"> 60g", key:"gradeA" as const },
B: { color:"#d97706", bg:"#fffbeb", borderColor:"#fde68a", label:"Grade B", range:"50 60g", key:"gradeB" as const },
C: { color:"#dc2626", bg:"#fef2f2", borderColor:"#fecaca", label:"Grade C", range:"< 50g", key:"gradeC" as const },
TL: { color:"#6b7280", bg:"#f1f5f9", borderColor:"#cbd5e1", label:"Tidak Layak", range:"Reject", key:"gradeTL" as const },
};
/** dd/mm/yyyy*/
const fromDisplay = (s: string): Date | null => {
const p = s.split("/");
if (p.length !== 3) return null;
const d = new Date(+p[2], +p[1]-1, +p[0]);
d.setHours(0,0,0,0);
return isNaN(d.getTime()) ? null : d;
};
/** Hitung total & history yg sdh di filter */
function buildSummary(data: EggRecord[]) {
const acc: Record<EggGrade, { count: number; totalW: number }> = {
A: { count:0, totalW:0 },
B: { count:0, totalW:0 },
C: { count:0, totalW:0 },
TL: { count:0, totalW:0 },
};
data.forEach(d => {
const g = d.grade in acc ? d.grade : "TL";
acc[g].count++;
acc[g].totalW += d.weight;
});
const avg = (g: EggGrade) =>
acc[g].count > 0 ? parseFloat((acc[g].totalW / acc[g].count).toFixed(1)) : 0;
const slotMap: Record<string, Record<EggGrade, number>> = {};
data.forEach(d => {
const [hStr, mStr] = d.timestamp.split(":");
const h = parseInt(hStr) || 0;
const m = parseInt(mStr) || 0;
const slot30 = `${String(h).padStart(2,"0")}:${m < 30 ? "00" : "30"}`;
const key = `${d.date} ${slot30}`;
if (!slotMap[key]) slotMap[key] = { A:0, B:0, C:0, TL:0 };
const g = d.grade in slotMap[key] ? d.grade : "TL";
slotMap[key][g]++;
});
const history: HistoryPoint[] = Object.entries(slotMap)
.sort(([a],[b]) => a.localeCompare(b))
.slice(-5)
.map(([key, v]) => ({
time: key.split(" ")[1],
A: v.A, B: v.B, C: v.C, TL: v.TL,
}));
return {
gradeA: { count: acc.A.count, avgWeight: avg("A") },
gradeB: { count: acc.B.count, avgWeight: avg("B") },
gradeC: { count: acc.C.count, avgWeight: avg("C") },
gradeTL: { count: acc.TL.count, avgWeight: avg("TL") },
history,
};
}
// Data dummy
const _today = new Date();
const MOCK_DATA: EggRecord[] = Array.from({ length: 50 }, (_, i) => {
const grades: EggGrade[] = ["A","B","C","TL"];
const grade = grades[i % 4];
const weightMap = {
A: 64,
B: 55,
C: 47,
TL: 30,
};
const d = new Date(_today);
d.setDate(_today.getDate() - Math.floor(i / 10));
d.setHours(8 + (i % 10), 0, 0, 0);
return {
id: i + 1,
grade,
weight: weightMap[grade],
timestamp: `${String(d.getHours()).padStart(2,"0")}:00`,
date: `${String(d.getDate()).padStart(2,"0")}/${String(d.getMonth()+1).padStart(2,"0")}/${d.getFullYear()}`,
};
});
// ─Komponen utama
export default function DashboardScreen() {
const todayISO = toISO(new Date());
const [allData, setAllData] = useState<EggRecord[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// const [usingMock, setUsingMock] = useState(false);
const [dateFrom, setDateFrom] = useState(todayISO);
const [dateTo, setDateTo] = useState(todayISO);
const [showCalFrom, setShowCalFrom] = useState(false);
const [showCalTo, setShowCalTo] = useState(false);
// ── Fetch semua data sekali, filter di frontend ───────────────────────────
useEffect(() => {
const fetch = async () => {
try {
const result = await getEggData();
setAllData(result);
// setUsingMock(false);
setError(null);
} catch {
// setAllData(MOCK_DATA);
// setUsingMock(true);
setAllData([]);
setError("Server belum terhubung");
} finally {
setLoading(false);
}
};
fetch();
const iv = setInterval(fetch, 5000);
return () => clearInterval(iv);
}, []);
// ── Filter data berdasarkan rentang tanggal ───────────────────────────────
const filtered = allData.filter(d => {
const itemDate = fromDisplay(d.date);
const from = fromISO(dateFrom);
const to = fromISO(dateTo);
to.setHours(23,59,59,999);
return itemDate ? itemDate >= from && itemDate <= to : true;
});
// ── Hitung summary dari data yang sudah difilter ──────────────────────────
const summary = buildSummary(filtered);
const totalEggs =
summary.gradeA.count + summary.gradeB.count +
summary.gradeC.count + summary.gradeTL.count;
const rangeLabel = dateFrom === dateTo
? `Hari ini, ${toDisplay(dateFrom)}`
: `${toDisplay(dateFrom)} ${toDisplay(dateTo)}`;
// Helper ambil data per key
const getSummaryByKey = (key: "gradeA"|"gradeB"|"gradeC"|"gradeTL") => summary[key];
const getSparkByGrade = (grade: EggGrade) => summary.history.map(h => h[grade]);
return (
<ScrollView
style={s.container}
contentContainerStyle={s.content}
showsVerticalScrollIndicator={false}
>
{/* Header */}
<View style={s.header}>
<View>
<Text style={s.title}>Dashboard</Text>
<Text style={s.subtitle}>Live · Refresh 5s</Text>
</View>
<View style={[
s.statusBadge,
{
backgroundColor: error ? "#fef2f2" : "#f0fdf4",
borderColor: error ? "#fecaca" : "#bbf7d0"
}
]}>
<View style={[
s.statusDot,
{ backgroundColor: error ? "#dc2626" : "#16a34a" }
]} />
<Text style={[
s.statusText,
{ color: error ? "#dc2626" : "#16a34a" }
]}>
{error ? "OFFLINE" : "ONLINE"}
</Text>
</View>
</View>
{/* Filter Tanggal */}
<View style={s.dateCard}>
<View style={s.dateRangeRow}>
{/* Dari */}
<View style={s.dateField}>
<Text style={s.dateFieldLabel}>Dari Tanggal</Text>
<TouchableOpacity onPress={() => setShowCalFrom(true)} style={s.dateFieldBtn}>
<Text style={s.dateFieldText}>{toDisplay(dateFrom)}</Text>
</TouchableOpacity>
</View>
<Text style={s.dateDash}></Text>
{/* Sampai */}
<View style={s.dateField}>
<Text style={s.dateFieldLabel}>Sampai Tanggal</Text>
<TouchableOpacity onPress={() => setShowCalTo(true)} style={s.dateFieldBtn}>
<Text style={s.dateFieldText}>{toDisplay(dateTo)}</Text>
</TouchableOpacity>
</View>
</View>
{/* Label aktif + Reset */}
<View style={s.activeRow}>
{(dateFrom!==todayISO || dateTo!==todayISO) && (
<TouchableOpacity onPress={() => { setDateFrom(todayISO); setDateTo(todayISO); }}>
<Text style={s.resetText}>Reset ke Hari Ini</Text>
</TouchableOpacity>
)}
</View>
</View>
{/* Total Card */}
<View style={s.totalCard}>
<View>
<Text style={s.totalLabel}>Total Telur</Text>
{loading ? (
<ActivityIndicator color="#4f46e5" style={{marginTop:8}} />
) : (
<Text style={s.totalValue}>{String(totalEggs).padStart(3,"0")}</Text>
)}
<Text style={s.totalSub}>{rangeLabel}</Text>
</View>
<View style={s.gradeBadgeRow}>
<Text style={s.totalSubLabel}>Grade</Text>
<View style={s.gradeBadgesContainer}>
{(["A","B","C","TL"] as const).map((g) => (
<View key={g} style={[s.gradeBadge,{
backgroundColor: GRADE_CONFIG[g].bg,
borderColor: GRADE_CONFIG[g].borderColor,
}]}>
<Text style={[s.gradeBadgeText,{color:GRADE_CONFIG[g].color}]}>{g}</Text>
</View>
))}
</View>
</View>
</View>
{/* Grade Cards — A, B, C, TL */}
{(["A","B","C","TL"] as const).map((grade) => {
const cfg = GRADE_CONFIG[grade];
const d = getSummaryByKey(cfg.key);
const sparkData = getSparkByGrade(grade);
return (
<View key={grade} style={[s.gradeCard,{backgroundColor:cfg.bg,borderColor:cfg.borderColor}]}>
<View style={s.gradeCardInner}>
<View style={{flex:1}}>
<View style={s.gradeTagRow}>
<View style={[s.gradeTag,{backgroundColor:cfg.color}]}>
<Text style={s.gradeTagText}>{cfg.label}</Text>
</View>
<Text style={s.gradeRange}>{cfg.range}</Text>
</View>
<View style={s.statsRow}>
<View>
<Text style={s.statLabel}>Jumlah</Text>
<Text style={[s.statValue,{color:cfg.color}]}>
{loading ? "—" : d.count}
</Text>
<Text style={s.statUnit}>butir</Text>
</View>
<View style={{marginLeft:24}}>
<Text style={s.statLabel}>Rata-rata</Text>
<Text style={[s.statValue,{color:"#1e293b"}]}>
{loading ? "—" : d.avgWeight > 0 ? d.avgWeight.toFixed(1) : "0"}
</Text>
<Text style={s.statUnit}>gram</Text>
</View>
</View>
</View>
<View style={s.sparklineContainer}>
{sparkData.length >= 2
? <Sparkline data={sparkData} color={cfg.color} width={80} height={40} />
: <View style={s.noSparkline}><Text style={s.noSparklineText}></Text></View>
}
</View>
</View>
</View>
);
})}
{/* Bar Chart */}
<View style={s.chartCard}>
<Text style={s.chartTitle}>Distribusi per Waktu · {rangeLabel}</Text>
{loading ? (
<ActivityIndicator color="#94a3b8" style={{marginTop:20}} />
) : summary.history.length > 0 ? (
<BarChart history={summary.history} />
) : (
<Text style={s.emptyChart}>Belum ada data pada rentang tanggal ini</Text>
)}
</View>
{/* Calendar popup — Dari */}
<CalendarPicker
visible={showCalFrom}
value={dateFrom}
maxDate={dateTo}
title="Pilih Tanggal Awal"
onConfirm={(v) => { setDateFrom(v); if (v > dateTo) setDateTo(v); }}
onClose={() => setShowCalFrom(false)}
/>
{/* Calendar popup — Sampai */}
<CalendarPicker
visible={showCalTo}
value={dateTo}
minDate={dateFrom}
maxDate={todayISO}
title="Pilih Tanggal Akhir"
onConfirm={(v) => setDateTo(v)}
onClose={() => setShowCalTo(false)}
/>
</ScrollView>
);
}
// ─── Styles ───────────────────────────────────────────────────────────────────
const s = StyleSheet.create({
container: { flex:1, backgroundColor:"#f8fafc" },
content: { padding:16, paddingBottom:24 },
header: { flexDirection:"row", justifyContent:"space-between", alignItems:"center", marginBottom:14 },
title: { fontSize:24, fontWeight:"800", color:"#0f172a", letterSpacing:-0.5 },
subtitle: { fontSize:12, color:"#94a3b8", marginTop:2 },
statusBadge:{ flexDirection:"row", alignItems:"center", gap:6, borderWidth:1, borderRadius:20, paddingHorizontal:12, paddingVertical:6 },
statusDot: { width:7, height:7, borderRadius:4 },
statusText: { fontSize:10, fontWeight:"700" },
mockBanner: { backgroundColor:"#fffbeb", borderWidth:1, borderColor:"#fde68a", borderRadius:10, padding:9, marginBottom:12 },
mockText: { fontSize:11, color:"#92400e" },
// Date card
dateCard: { backgroundColor:"#fff", borderWidth:1, borderColor:"#e2e8f0", borderRadius:14, padding:12, marginBottom:14 },
dateRangeRow: { flexDirection:"row", alignItems:"center", gap:8 },
dateField: { flex:1 },
dateFieldLabel: { fontSize:10, color:"#94a3b8", fontWeight:"600", marginBottom:4 },
dateFieldBtn: { flexDirection:"row", alignItems:"center", gap:6, backgroundColor:"#f8fafc", borderWidth:1, borderColor:"#e2e8f0", borderRadius:10, paddingHorizontal:10, paddingVertical:9 },
dateFieldIcon: { fontSize:14 },
dateFieldText: { fontSize:13, color:"#0f172a", fontWeight:"600" },
dateDash: { fontSize:18, color:"#cbd5e1", marginTop:16 },
activeRow: { flexDirection:"row", justifyContent:"space-between", alignItems:"center", marginTop:10 },
activeText: { fontSize:11, color:"#64748b" },
resetText: { fontSize:11, color:"#4f46e5", fontWeight:"700" },
// Total Card
totalCard: { backgroundColor:"#eef2ff", borderWidth:1, borderColor:"#c7d2fe", borderRadius:16, padding:16, marginBottom:14, flexDirection:"row", justifyContent:"space-between", alignItems:"center" },
totalLabel: { fontSize:11, color:"#4f46e5", marginBottom:2, fontWeight:"600" },
totalValue: { fontSize:36, fontWeight:"800", color:"#1e1b4b", fontFamily:"monospace" },
totalSub: { fontSize:10, color:"#818cf8", marginTop:2 },
gradeBadgeRow: { alignItems:"flex-end" },
totalSubLabel: { fontSize:11, color:"#94a3b8", marginBottom:6 },
gradeBadgesContainer:{ flexDirection:"row", gap:5 },
gradeBadge: { width:28, height:28, borderRadius:7, borderWidth:1, alignItems:"center", justifyContent:"center" },
gradeBadgeText: { fontSize:9, fontWeight:"700" },
// Grade Cards
gradeCard: { borderWidth:1, borderRadius:16, padding:14, marginBottom:12 },
gradeCardInner: { flexDirection:"row", justifyContent:"space-between", alignItems:"flex-start" },
gradeTagRow: { flexDirection:"row", alignItems:"center", gap:8, marginBottom:10 },
gradeTag: { paddingHorizontal:8, paddingVertical:3, borderRadius:6 },
gradeTagText: { fontSize:11, fontWeight:"800", color:"#fff" },
gradeRange: { fontSize:10, color:"#94a3b8" },
statsRow: { flexDirection:"row" },
statLabel: { fontSize:10, color:"#94a3b8", marginBottom:2 },
statValue: { fontSize:28, fontWeight:"800", fontFamily:"monospace", lineHeight:32 },
statUnit: { fontSize:9, color:"#94a3b8" },
sparklineContainer: { alignItems:"flex-end", paddingTop:4 },
noSparkline: { width:80, height:40, justifyContent:"center", alignItems:"center" },
noSparklineText:{ color:"#cbd5e1", fontSize:18 },
// Chart
chartCard: { backgroundColor:"#fff", borderWidth:1, borderColor:"#e2e8f0", borderRadius:16, padding:16 },
chartTitle: { fontSize:12, color:"#64748b", fontWeight:"600", marginBottom:12 },
emptyChart: { fontSize:12, color:"#94a3b8", textAlign:"center", paddingVertical:20 },
});

View File

@ -0,0 +1,452 @@
import React, { useEffect, useState } from "react";
import {
View, Text, StyleSheet, TouchableOpacity, TextInput,
ActivityIndicator, Alert, FlatList, Modal,
} from "react-native";
import RNPrint from "react-native-print";
import CalendarPicker, { toISO, fromISO, toDisplay } from "../components/CalendarPicker";
import { getEggData, EggRecord, EggGrade } from "../services/api";
const GRADE_CONFIG = {
A: { color: "#16a34a", bg: "#f0fdf4", borderColor: "#bbf7d0" },
B: { color: "#d97706", bg: "#fffbeb", borderColor: "#fde68a" },
C: { color: "#dc2626", bg: "#fef2f2", borderColor: "#fecaca" },
TL: { color: "#6b7280", bg: "#f1f5f9", borderColor: "#cbd5e1" },
};
const fromDisplay = (s: string): Date | null => {
const p = s.split("/");
if (p.length !== 3) return null;
const d = new Date(+p[2], +p[1]-1, +p[0]);
d.setHours(0,0,0,0);
return isNaN(d.getTime()) ? null : d;
};
type GradeFilter = "all" | EggGrade;
export default function laporanScreen() {
const todayISO = toISO(new Date());
const [allData, setAllData] = useState<EggRecord[]>([]);
const [gradeFilter, setGradeFilter] = useState<GradeFilter>("all");
const [dateFrom, setDateFrom] = useState(todayISO);
const [dateTo, setDateTo] = useState(todayISO);
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(true);
const [printing, setPrinting] = useState(false);
const [showCalFrom, setShowCalFrom] = useState(false);
const [showCalTo, setShowCalTo] = useState(false);
useEffect(() => {
const fetch = async () => {
try {
const result = await getEggData();
setAllData(result);
} catch (err) {
setAllData([]);
} finally { setLoading(false); }
};
fetch();
const iv = setInterval(fetch, 8000);
return () => clearInterval(iv);
}, []);
// ── Filter ────────────────────────────────────────────────────────────────
const filtered = allData.filter((d) => {
const matchGrade = gradeFilter === "all" || d.grade === gradeFilter;
const itemDate = fromDisplay(d.date);
const from = fromISO(dateFrom);
const to = fromISO(dateTo); to.setHours(23,59,59,999);
const matchDate = itemDate ? itemDate >= from && itemDate <= to : true;
const matchSearch = !search || (
d.grade.toLowerCase().includes(search.toLowerCase()) ||
d.weight.toString().includes(search) || d.date.includes(search)
);
return matchGrade && matchDate && matchSearch;
});
const countByGrade = (g: EggGrade) => filtered.filter(d => d.grade === g).length;
const rangeLabel = dateFrom === dateTo
? `Hari ini, ${toDisplay(dateFrom)}`
: `${toDisplay(dateFrom)} ${toDisplay(dateTo)}`;
// ── PDF ───────────────────────────────────────────────────────────────────
const handlePrintPDF = async () => {
setPrinting(true);
try {
const gradeLabel = gradeFilter === "all" ? "Semua Grade" : `Grade ${gradeFilter}`;
const printDate = new Date().toLocaleString("id-ID",{
weekday:"long", year:"numeric", month:"long", day:"numeric", hour:"2-digit", minute:"2-digit"
});
const pct = (n: number) => filtered.length > 0 ? ((n/filtered.length)*100).toFixed(1) : "0.0";
const cntA = countByGrade("A"), cntB = countByGrade("B"),
cntC = countByGrade("C"), cntTL = countByGrade("TL");
const rows = filtered.length === 0
? `<tr><td colspan="5" style="text-align:center;padding:32px;color:#94a3b8;font-style:italic">Tidak ada data sesuai filter</td></tr>`
: filtered.map((d,i) => `
<tr>
<td class="tc muted">${i+1}</td>
<td>${d.date}</td>
<td class="muted">${d.timestamp}</td>
<td class="tc"><span class="badge grade-${d.grade}">${d.grade==="TL"?"Tidak Layak":"Grade "+d.grade}</span></td>
<td class="tr mono">${d.weight} g</td>
</tr>`).join("");
const html = `<!DOCTYPE html>
<html lang="id"><head><meta charset="UTF-8"><title>Laporan Sortir Telur</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:Arial,sans-serif;font-size:12px;color:#1e293b;padding:28px 32px}
.ph{display:flex;justify-content:space-between;align-items:flex-start;border-bottom:2px solid #e2e8f0;padding-bottom:14px;margin-bottom:20px}
.ph h1{font-size:18px;font-weight:800;color:#0f172a}.ph p{font-size:11px;color:#64748b;margin-top:2px}
.meta{text-align:right;font-size:11px;color:#64748b;line-height:1.8}
.bf{display:inline-block;background:#eef2ff;color:#4f46e5;border:1px solid #c7d2fe;border-radius:20px;padding:3px 12px;font-size:11px;font-weight:600;margin-top:4px}
.sg{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;margin-bottom:20px}
.sc{border-radius:10px;border:1px solid #e2e8f0;padding:12px 8px;text-align:center;position:relative;overflow:hidden}
.sc::before{content:'';position:absolute;top:0;left:0;right:0;height:3px}
.sc.tot::before{background:#1e293b}.sc.tot .num{color:#0f172a}
.sc.grA{background:#f0fdf4;border-color:#bbf7d0}.sc.grA::before{background:#16a34a}.sc.grA .num{color:#16a34a}
.sc.grB{background:#fffbeb;border-color:#fde68a}.sc.grB::before{background:#d97706}.sc.grB .num{color:#d97706}
.sc.grC{background:#fef2f2;border-color:#fecaca}.sc.grC::before{background:#dc2626}.sc.grC .num{color:#dc2626}
.sc.grT{background:#f8fafc;border-color:#cbd5e1}.sc.grT::before{background:#6b7280}.sc.grT .num{color:#6b7280}
.sc .num{font-size:26px;font-weight:800;line-height:1}.sc .lbl{font-size:10px;color:#64748b;margin-top:3px}.sc .pct{font-size:10px;color:#94a3b8}
.db{background:#f1f5f9;border-radius:6px;height:12px;overflow:hidden;display:flex;margin-bottom:8px}
.ds{height:100%}.ds.A{background:#16a34a}.ds.B{background:#d97706}.ds.C{background:#dc2626}.ds.TL{background:#94a3b8}
.dl{display:flex;gap:14px;flex-wrap:wrap;margin-bottom:20px}
.di{display:flex;align-items:center;gap:5px;font-size:10px;color:#64748b}
.dot{width:8px;height:8px;border-radius:50%}.dot.A{background:#16a34a}.dot.B{background:#d97706}.dot.C{background:#dc2626}.dot.TL{background:#94a3b8}
.ts h2{font-size:11px;color:#94a3b8;font-weight:600;text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px}
table{width:100%;border-collapse:collapse}thead tr{background:#0f172a}
th{padding:9px 10px;text-align:left;font-size:10px;font-weight:600;color:#94a3b8;text-transform:uppercase;letter-spacing:.4px}
td{padding:8px 10px;font-size:11px;border-bottom:1px solid #f1f5f9}tr:nth-child(even) td{background:#f8fafc}
.tc{text-align:center}.tr{text-align:right}.muted{color:#94a3b8}.mono{font-family:'Courier New',monospace;font-weight:600}
.badge{display:inline-block;padding:2px 8px;border-radius:20px;font-weight:700;font-size:10px}
.grade-A{background:#dcfce7;color:#15803d;border:1px solid #bbf7d0}
.grade-B{background:#fef9c3;color:#b45309;border:1px solid #fde68a}
.grade-C{background:#fee2e2;color:#b91c1c;border:1px solid #fecaca}
.grade-TL{background:#f1f5f9;color:#4b5563;border:1px solid #cbd5e1}
.pf{margin-top:20px;padding-top:12px;border-top:1px solid #e2e8f0;display:flex;justify-content:space-between;font-size:10px;color:#94a3b8}
@media print{body{padding:14px 18px}tr{page-break-inside:avoid}}
</style></head><body>
<div class="ph">
<div><h1>🥚 Laporan Sortir Telur</h1><p>Sistem Sortir Telur Otomatis NAKULA</p></div>
<div class="meta">Dicetak: ${printDate}<br><span class="bf">${gradeLabel} · ${rangeLabel}</span></div>
</div>
<div class="sg">
<div class="sc tot"><div class="num">${filtered.length}</div><div class="lbl">Total</div><div class="pct">100%</div></div>
<div class="sc grA"><div class="num">${cntA}</div><div class="lbl">Grade A</div><div class="pct">${pct(cntA)}%</div></div>
<div class="sc grB"><div class="num">${cntB}</div><div class="lbl">Grade B</div><div class="pct">${pct(cntB)}%</div></div>
<div class="sc grC"><div class="num">${cntC}</div><div class="lbl">Grade C</div><div class="pct">${pct(cntC)}%</div></div>
<div class="sc grT"><div class="num">${cntTL}</div><div class="lbl">Tdk Layak</div><div class="pct">${pct(cntTL)}%</div></div>
</div>
${filtered.length>0?`
<div class="db">
<div class="ds A" style="width:${pct(cntA)}%"></div>
<div class="ds B" style="width:${pct(cntB)}%"></div>
<div class="ds C" style="width:${pct(cntC)}%"></div>
<div class="ds TL" style="width:${pct(cntTL)}%"></div>
</div>
<div class="dl">
<div class="di"><div class="dot A"></div>Grade A ${cntA} butir (${pct(cntA)}%)</div>
<div class="di"><div class="dot B"></div>Grade B ${cntB} butir (${pct(cntB)}%)</div>
<div class="di"><div class="dot C"></div>Grade C ${cntC} butir (${pct(cntC)}%)</div>
<div class="di"><div class="dot TL"></div>Tidak Layak ${cntTL} butir (${pct(cntTL)}%)</div>
</div>`:""}
<div class="ts">
<h2>Detail Data (${filtered.length} butir)</h2>
<table>
<thead><tr><th style="width:32px">#</th><th>Tanggal</th><th>Jam</th><th class="tc">Grade</th><th class="tr">Berat</th></tr></thead>
<tbody>${rows}</tbody>
</table>
</div>
<div class="pf">
<span>Sistem Sortir Telur Otomatis © ${new Date().getFullYear()}</span>
<span>${gradeLabel} · ${rangeLabel}</span>
</div>
</body></html>`;
await RNPrint.print({ html });
} catch (e: any) {
Alert.alert("Gagal", e?.message ?? "Tidak dapat membuka dialog cetak.");
} finally { setPrinting(false); }
};
// ── Render row ────────────────────────────────────────────────────────────
const renderItem = ({ item, index }: { item: EggRecord; index: number }) => {
const cfg = GRADE_CONFIG[item.grade] ?? GRADE_CONFIG.TL;
return (
<View style={[s.tableRow, { backgroundColor: index%2===0 ? "#fff" : "#f8fafc" }]}>
<Text style={[s.cell, s.cellId]}>{item.id}</Text>
<Text style={[s.cell, s.cellDate]}>{item.date}</Text>
<Text style={[s.cell, s.cellTime]}>{item.timestamp}</Text>
<View style={s.cellGradeCont}>
<View style={[s.gradeBadge, { backgroundColor: cfg.bg, borderColor: cfg.borderColor }]}>
<Text style={[s.gradeBadgeText, { color: cfg.color }]}>
{item.grade==="TL" ? "Tdk Layak" : `Grade ${item.grade}`}
</Text>
</View>
</View>
<Text style={[s.cell, s.cellWeight]}>{item.weight}g</Text>
</View>
);
};
return (
<View style={s.container}>
{/* Header */}
<View style={s.header}>
<View>
<Text style={s.title}>Laporan</Text>
<Text style={s.subtitle}>Data realtime sortir telur</Text>
</View>
</View>
{/* <View style={[
s.statusBanner,
{
backgroundColor: error ? "#fef2f2" : "#f0fdf4",
borderColor: error ? "#fecaca" : "#bbf7d0"
}
]}>
<Text style={[
s.statusText,
{ color: error ? "#dc2626" : "#16a34a" }
]}>
{error ? "🔴 OFFLINE — Server tidak terhubung" : "🟢 ONLINE — Terhubung ke server"}
</Text>
</View> */}
{/* Filter Grade */}
<View style={s.filterRow}>
{(["all","A","B","C","TL"] as const).map((g) => {
const active = gradeFilter === g;
const color = g==="all" ? "#4f46e5" : GRADE_CONFIG[g as EggGrade].color;
const bg = g==="all" ? "#eef2ff" : GRADE_CONFIG[g as EggGrade].bg;
const border = g==="all" ? "#c7d2fe" : GRADE_CONFIG[g as EggGrade].borderColor;
return (
<TouchableOpacity key={g} onPress={() => setGradeFilter(g)}
style={[s.filterChip,{ borderColor: active?border:"#e2e8f0", backgroundColor: active?bg:"#fff" }]}>
<Text style={[s.filterChipText,{ color: active?color:"#94a3b8", fontWeight: active?"700":"400" }]}>
{g==="all"?"Semua":g==="TL"?"TL":`Grade ${g}`}
</Text>
</TouchableOpacity>
);
})}
</View>
{/* Filter Tanggal — popup calendar */}
<View style={s.dateRangeCard}>
<View style={s.dateRangeRow}>
{/* Dari */}
<View style={s.dateField}>
<Text style={s.dateFieldLabel}>Dari Tanggal</Text>
<TouchableOpacity onPress={() => setShowCalFrom(true)} style={s.dateFieldBtn}>
{/* <Text style={s.dateFieldIcon}>📅</Text> */}
<Text style={s.dateFieldText}>{toDisplay(dateFrom)}</Text>
</TouchableOpacity>
</View>
<Text style={s.dateDash}></Text>
{/* Sampai */}
<View style={s.dateField}>
<Text style={s.dateFieldLabel}>Sampai Tanggal</Text>
<TouchableOpacity onPress={() => setShowCalTo(true)} style={s.dateFieldBtn}>
{/* <Text style={s.dateFieldIcon}>📅</Text> */}
<Text style={s.dateFieldText}>{toDisplay(dateTo)}</Text>
</TouchableOpacity>
</View>
</View>
{/* Label aktif + Reset */}
<View style={s.activeRow}>
<Text style={s.activeText}>
{rangeLabel}{" · "}
<Text style={{color:"#4f46e5",fontWeight:"700"}}>{filtered.length} data</Text>
</Text>
{(dateFrom!==todayISO || dateTo!==todayISO) && (
<TouchableOpacity onPress={() => { setDateFrom(todayISO); setDateTo(todayISO); }}>
<Text style={s.resetText}>Reset</Text>
</TouchableOpacity>
)}
</View>
</View>
{/* Search */}
<TextInput
value={search} onChangeText={setSearch}
placeholder="Cari grade / berat"
placeholderTextColor="#cbd5e1"
style={s.searchInput}
/>
{/* Stats */}
<View style={s.statsRow}>
{(["A","B","C","TL"] as const).map((g) => {
const cnt = countByGrade(g); const cfg = GRADE_CONFIG[g];
return (
<View key={g} style={[s.statBox,{
backgroundColor: cnt>0?cfg.bg:"#f8fafc",
borderColor: cnt>0?cfg.borderColor:"#e2e8f0",
}]}>
<Text style={[s.statNum,{color:cfg.color}]}>{cnt}</Text>
<Text style={s.statLabel}>{g==="TL"?"TL":`Grade ${g}`}</Text>
</View>
);
})}
<View style={[s.statBox,{backgroundColor:"#f1f5f9",borderColor:"#e2e8f0"}]}>
<Text style={[s.statNum,{color:"#1e293b"}]}>{filtered.length}</Text>
<Text style={s.statLabel}>Total</Text>
</View>
</View>
{/* Table Header */}
<View style={s.tableHeader}>
<Text style={[s.thText, s.cellId]}>#</Text>
<Text style={[s.thText, s.cellDate]}>Tanggal</Text>
<Text style={[s.thText, s.cellTime]}>Waktu</Text>
<Text style={[s.thText, s.cellGrade]}>Grade</Text>
<Text style={[s.thText, s.cellWeight]}>Berat</Text>
</View>
{/* Table Body */}
{loading ? (
<ActivityIndicator color="#94a3b8" style={{marginTop:40}} />
) : filtered.length===0 ? (
<Text style={s.emptyText}>Tidak ada data pada rentang tanggal ini</Text>
) : (
<FlatList
data={filtered}
keyExtractor={item => item.id.toString()}
renderItem={renderItem}
showsVerticalScrollIndicator={false}
style={s.tableBody}
contentContainerStyle={{paddingBottom:90}}
/>
)}
{/* FAB PDF */}
<TouchableOpacity
onPress={handlePrintPDF} disabled={printing}
style={[s.fab, printing && {opacity:0.7}]}
activeOpacity={0.85}
>
{printing
? <ActivityIndicator color="#fff" size="small" />
: <Text style={s.fabText}>PDF</Text>
}
</TouchableOpacity>
{/* Calendar — Dari */}
<CalendarPicker
visible={showCalFrom}
value={dateFrom}
maxDate={dateTo}
title="Pilih Tanggal Awal"
onConfirm={(v) => { setDateFrom(v); if (v > dateTo) setDateTo(v); }}
onClose={() => setShowCalFrom(false)}
/>
{/* Calendar — Sampai */}
<CalendarPicker
visible={showCalTo}
value={dateTo}
minDate={dateFrom}
maxDate={todayISO}
title="Pilih Tanggal Akhir"
onConfirm={(v) => setDateTo(v)}
onClose={() => setShowCalTo(false)}
/>
</View>
);
}
// ─── Styles ───────────────────────────────────────────────────────────────────
const s = StyleSheet.create({
container: { flex:1, backgroundColor:"#f8fafc", paddingTop:16, paddingHorizontal:16 },
header: { flexDirection:"row", justifyContent:"space-between", alignItems:"flex-start", marginBottom:10 },
title: { fontSize:24, fontWeight:"800", color:"#0f172a", letterSpacing:-0.5 },
subtitle: { fontSize:12, color:"#94a3b8", marginTop:2 },
// mockBanner: { backgroundColor:"#fffbeb", borderWidth:1, borderColor:"#fde68a", borderRadius:10, padding:9, marginBottom:10 },
// mockText: { fontSize:11, color:"#92400e" },
statusBanner: {
borderWidth: 1,
borderRadius: 10,
padding: 10,
marginBottom: 10,
},
statusText: {
fontSize: 12,
fontWeight: "600",
},
filterRow: { flexDirection:"row", gap:6, marginBottom:10 },
filterChip: { flex:1, paddingVertical:7, borderRadius:20, borderWidth:1, alignItems:"center" },
filterChipText:{ fontSize:11 },
// Date range card
dateRangeCard: {
backgroundColor:"#fff", borderWidth:1, borderColor:"#e2e8f0",
borderRadius:14, padding:12, marginBottom:10,
},
dateRangeRow: { flexDirection:"row", alignItems:"center", gap:8 },
dateField: { flex:1 },
dateFieldLabel:{ fontSize:10, color:"#94a3b8", fontWeight:"600", marginBottom:4 },
dateFieldBtn: {
flexDirection:"row", alignItems:"center", gap:6,
backgroundColor:"#f8fafc", borderWidth:1, borderColor:"#e2e8f0",
borderRadius:10, paddingHorizontal:10, paddingVertical:9,
},
dateFieldIcon: { fontSize:14 },
dateFieldText: { fontSize:13, color:"#0f172a", fontWeight:"600" },
dateDash: { fontSize:18, color:"#cbd5e1", marginTop:16 },
activeRow: { flexDirection:"row", justifyContent:"space-between", alignItems:"center", marginTop:10 },
activeText: { fontSize:11, color:"#64748b" },
resetText: { fontSize:11, color:"#4f46e5", fontWeight:"700" },
searchInput: {
backgroundColor:"#fff", borderWidth:1, borderColor:"#e2e8f0",
borderRadius:10, paddingHorizontal:12, paddingVertical:9,
color:"#0f172a", fontSize:13, marginBottom:10,
},
statsRow: { flexDirection:"row", gap:6, marginBottom:10 },
statBox: { flex:1, borderWidth:1, borderRadius:10, padding:6, alignItems:"center" },
statNum: { fontSize:16, fontWeight:"800", fontFamily:"monospace" },
statLabel:{ fontSize:8, color:"#94a3b8", marginTop:2 },
tableHeader: { flexDirection:"row", paddingVertical:10, paddingHorizontal:4, borderBottomWidth:1, borderBottomColor:"#e2e8f0" },
thText: { fontSize:10, color:"#94a3b8", fontWeight:"600" },
tableBody: { flex:1 },
tableRow: { flexDirection:"row", alignItems:"center", paddingVertical:9, paddingHorizontal:4, borderBottomWidth:1, borderBottomColor:"#f1f5f9" },
cell: { fontSize:11, color:"#334155" },
cellId: { width:28, color:"#94a3b8", fontFamily:"monospace", fontSize:10 },
cellDate: { width:68, fontSize:10, color:"#64748b" },
cellTime: { width:44, fontSize:10, color:"#64748b" },
cellGrade: { flex:1 },
cellGradeCont:{ flex:1 },
gradeBadge: { alignSelf:"flex-start", paddingHorizontal:6, paddingVertical:2, borderRadius:6, borderWidth:1 },
gradeBadgeText:{ fontSize:9, fontWeight:"700" },
cellWeight: { width:46, fontFamily:"monospace", fontWeight:"600", textAlign:"right", color:"#1e293b", fontSize:11 },
emptyText: { textAlign:"center", color:"#94a3b8", marginTop:40, fontSize:13 },
fab: {
position:"absolute", bottom:24, right:20,
backgroundColor:"#4f46e5", borderRadius:18,
paddingHorizontal:22, paddingVertical:14,
shadowColor:"#4f46e5", shadowOffset:{width:0,height:6},
shadowOpacity:0.4, shadowRadius:12, elevation:10,
},
fabText: { color:"#fff", fontSize:14, fontWeight:"800", letterSpacing:0.3 },
});

87
src/services/api.ts Normal file
View File

@ -0,0 +1,87 @@
import axios from "axios";
const BASE_URL = "http://10.10.1.112:5000";
// ─── Types ────────────────────────────────────────────────────────────────────
export type EggGrade = "A" | "B" | "C" | "TL";
export type Period = "today" | "week" | "month" | "all";
export interface GradeSummary {
count: number;
avgWeight: number;
}
export interface HistoryPoint {
time: string;
A: number;
B: number;
C: number;
TL: number;
}
export interface SummaryResponse {
gradeA: GradeSummary;
gradeB: GradeSummary;
gradeC: GradeSummary;
gradeTL: GradeSummary;
history: HistoryPoint[];
}
export interface EggRecord {
id: number;
timestamp: string;
date: string;
grade: EggGrade;
weight: number;
}
export interface LatestDataResponse {
berat: number;
grade: EggGrade;
timestamp: string;
}
// ─── Axios instance dengan timeout ───────────────────────────────────────────
const api = axios.create({
baseURL: BASE_URL,
timeout: 8000,
});
// ─── API Calls ────────────────────────────────────────────────────────────────
//Ambil semua data telur dari MySQL
export const getEggData = async (): Promise<EggRecord[]> => {
const res = await api.get("/telur");
return res.data;
};
//Ambil data telur terbaru.
export const getLatestData = async (): Promise<LatestDataResponse> => {
const res = await api.get("/latest");
return res.data;
};
// Ringkasan per grade + history chart untuk dashboard.
export const getSummary = async (period: Period = "today"): Promise<SummaryResponse> => {
const res = await api.get("/summary", { params: { period } });
return res.data;
};
//Set kecepatan conveyor (0255).
export const setConveyorSpeed = async (speed: number): Promise<void> => {
await api.post("/setSpeed", { speed });
};
//Ambil kecepatan conveyor saat ini.
export const getConveyorSpeed = async (): Promise<number> => {
const res = await api.get("/getSpeed");
return res.data.speed;
};