Upload project Nakula
51
App.tsx
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 34 KiB |
|
|
@ -4,7 +4,7 @@ buildscript {
|
|||
minSdkVersion = 24
|
||||
compileSdkVersion = 36
|
||||
targetSdkVersion = 36
|
||||
ndkVersion = "27.1.12297006"
|
||||
ndkVersion = "27.2.12479018"
|
||||
kotlinVersion = "2.1.20"
|
||||
}
|
||||
repositories {
|
||||
|
|
|
|||
5
app.json
|
|
@ -1,4 +1,7 @@
|
|||
{
|
||||
"name": "Nakula",
|
||||
"displayName": "Nakula"
|
||||
"displayName": "Nakula",
|
||||
"plugins": [
|
||||
"expo-sharing"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 11 KiB |
23
package.json
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
});
|
||||
|
|
@ -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" },
|
||||
});
|
||||
|
|
@ -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",
|
||||
},
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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",
|
||||
},
|
||||
});
|
||||
|
|
@ -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 (0–255)</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 },
|
||||
});
|
||||
|
|
@ -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 },
|
||||
});
|
||||
|
|
@ -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 },
|
||||
});
|
||||
|
|
@ -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 (0–255).
|
||||
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;
|
||||
};
|
||||