DONE: first init

This commit is contained in:
ISMAIL MASSERAN
2026-07-20 16:10:22 +08:00
commit 3189e3a1e3
272 changed files with 48853 additions and 0 deletions
+35
View File
@@ -0,0 +1,35 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
# Native
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
+12
View File
@@ -0,0 +1,12 @@
# Build instructions
```
cd mobile-app
```
```
npm install
```
```
npx expo start
```
### - open Expo Go application
### - scan QR code displayed in terminal
+40
View File
@@ -0,0 +1,40 @@
{
"expo": {
"name": "mobile-app",
"slug": "mobile-app",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"assetBundlePatterns": [
"**/*"
],
"ios": {
"supportsTablet": true
},
"android": {
"permissions": [
"READ_EXTERNAL_STORAGE",
"WRITE_EXTERNAL_STORAGE"
],
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff"
},
"package": "com.emacaga.mobileapp"
},
"web": {
"favicon": "./assets/favicon.png"
},
"extra": {
"eas": {
"projectId": "2a75751f-3867-4465-8c1d-6832a25c2b44"
}
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

+6
View File
@@ -0,0 +1,6 @@
module.exports = function(api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
};
};
+21
View File
@@ -0,0 +1,21 @@
{
"build": {
"preview": {
"android": {
"buildType": "apk"
}
},
"preview2": {
"android": {
"gradleCommand": ":app:assembleRelease"
}
},
"preview3": {
"developmentClient": true
},
"preview4": {
"distribution": "internal"
},
"production": {}
}
}
+18023
View File
File diff suppressed because it is too large Load Diff
+51
View File
@@ -0,0 +1,51 @@
{
"name": "mobile-app",
"version": "1.0.0",
"main": "./src/App.tsx",
"scripts": {
"ts:check": "tsc",
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web"
},
"dependencies": {
"@expo/metro-runtime": "~3.1.3",
"@react-native-async-storage/async-storage": "^1.23.1",
"@react-native-community/masked-view": "^0.1.11",
"@react-navigation/bottom-tabs": "^6.5.20",
"@react-navigation/native": "^6.1.17",
"@react-navigation/stack": "^6.3.29",
"@testing-library/react": "^14.2.2",
"@testing-library/react-native": "^12.4.4",
"buffer": "^6.0.3",
"expo": "~50.0.14",
"expo-file-system": "^16.0.9",
"expo-font": "^11.10.3",
"expo-sharing": "^11.10.0",
"expo-splash-screen": "^0.26.4",
"expo-status-bar": "~1.11.1",
"jest-fetch-mock": "^3.0.3",
"pdf-lib": "^1.17.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-native": "0.73.6",
"react-native-fs": "^2.20.0",
"react-native-gesture-handler": "~2.14.0",
"react-native-reanimated": "^3.6.2",
"react-native-safe-area-context": "^4.8.2",
"react-native-screens": "^3.29.0",
"react-native-web": "~0.19.6",
"react-navigation": "^5.0.0",
"rn-fetch-blob": "^0.12.0"
},
"devDependencies": {
"@babel/core": "^7.20.0",
"@testing-library/jest-dom": "^6.4.2",
"@types/jest": "^29.5.12",
"@types/react": "~18.2.45",
"jest": "^29.7.0",
"typescript": "^5.4.3"
},
"private": true
}
+10
View File
@@ -0,0 +1,10 @@
import { registerRootComponent } from "expo";
import AppNavigator from "./navigation/AppNavigator";
import AsyncStorage from "@react-native-async-storage/async-storage";
export default function App() {
AsyncStorage.clear();
return AppNavigator();
}
registerRootComponent(App);
@@ -0,0 +1,81 @@
import React, { useState, useEffect } from 'react';
import { View, Modal, Text, Button, StyleSheet, Dimensions } from 'react-native';
import { Colors } from '../constants/colors';
import { generateTicket } from '../services/fetchData';
import { getExpoToken } from '../utils/tokenUtils';
const windowWidth = Dimensions.get('window').width;
const windowHeight = Dimensions.get('window').height;
export default function ({ visible, onClose, number }: { visible: any, onClose: any, number: number}) {
const [modalVisible, setModalVisible] = useState(visible);
useEffect(() => {
setModalVisible(visible);
if (visible) {
const timer = setTimeout(() => {
setModalVisible(false);
onClose();
}, 5000);
return () => clearTimeout(timer);
}
}, [visible]);
if(number != 0 )
return (
<Modal
animationType="slide"
transparent={true}
visible={modalVisible}
onRequestClose={() => {
onClose();
}}
>
<View style={styles.modalContainer}>
<View style={styles.modalContent}>
<Text style={styles.message}>Vaš broj je:</Text>
<View style={styles.numberContainer}>
<Text style={styles.number}>{number}</Text>
</View>
<View style={styles.buttonContainer}>
<Button title="Dismiss" onPress={onClose} color={Colors.ACCENT} />
</View>
</View>
</View>
</Modal>
);
};
const styles = StyleSheet.create({
modalContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.5)', // Semi-transparent background
},
modalContent: {
backgroundColor: 'white',
padding: windowWidth * 0.05, // Responsive padding
borderRadius: windowWidth * 0.05, // Responsive border radius
width: windowWidth * 0.8, // Responsive width
alignItems: 'center', // Center content horizontally
},
message: {
fontSize: windowWidth * 0.05, // Responsive font size
marginBottom: windowHeight * 0.02, // Responsive margin
},
numberContainer: {
marginBottom: windowHeight * 0.04, // Responsive margin
},
number: {
fontSize: windowWidth * 0.12, // Responsive font size
fontWeight: 'bold',
},
buttonContainer: {
marginTop: windowHeight * 0.02, // Responsive margin
alignSelf: 'flex-end', // Align button to the right
},
});
@@ -0,0 +1,16 @@
import { View } from "react-native";
import React from "react";
import LottieView from "lottie-react-native";
export default function LoadingAnimation() {
return (
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
<LottieView
source={require("../../assets/animations/loading.json")}
autoPlay
loop
style={{ width: 200, height: 200 }}
/>
</View>
);
}
@@ -0,0 +1,58 @@
import React from "react";
import {
View,
Text,
StyleSheet,
SafeAreaView,
Dimensions,
Animated,
} from "react-native";
import { Colors } from "../constants/colors";
interface WelcomeMessageProps {
name: string;
welcome: string;
font: string;
}
export default function WelcomeMessage(params: WelcomeMessageProps) {
const screenWidth = Dimensions.get("window").width;
const fontSize = screenWidth * 0.09;
return (
<SafeAreaView style={styles.container}>
<Animated.Text style={[styles.title, { fontSize }]}>
{params.name}
</Animated.Text>
<View style={styles.borderContainer}>
<Text style={[styles.message, { fontFamily: params.font }]}>
{params.welcome}
</Text>
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
alignItems: "center",
},
title: {
color: Colors.PRIMARY,
fontWeight: "bold",
marginBottom: 30,
},
borderContainer: {
borderWidth: 1,
padding: 10,
width: "90%",
alignItems: "center",
borderColor: Colors.ACCENT,
borderRadius: 10,
},
message: {
textAlign: "center",
fontSize: 40,
color: Colors.PRIMARY,
},
});
+3
View File
@@ -0,0 +1,3 @@
//export const SERVER_URL = 'http://localhost:8080';
//export const SERVER_URL = 'http://192.168.1.5:8080';
export const SERVER_URL = 'http://localhost:8080';
+13
View File
@@ -0,0 +1,13 @@
export const Assets = {
DEFAULT_LOGO: require("../../assets/images/default_logo.jpg"),
ARIAL: require("../../assets/fonts/arial.ttf"),
HELVETICA: require("../../assets/fonts/helvetica.ttf"),
TIMES_NEW_ROMAN: require("../../assets/fonts/times-new-roman.ttf"),
FUTURA: require("../../assets/fonts/futura.ttf"),
MONTSERRAT: require("../../assets/fonts/montserrat.ttf"),
ROCKWELL: require("../../assets/fonts/rockwell.ttf"),
VERDANA: require("../../assets/fonts/verdana.ttf"),
CALIBRI: require("../../assets/fonts/calibri.ttf"),
BODONI: require("../../assets/fonts/bodoni.ttf"),
COMIC_SANS_MS: require("../../assets/fonts/comic-sans-ms.ttf"),
};
+6
View File
@@ -0,0 +1,6 @@
export const Colors = {
PRIMARY : "#334257",
SECONDARY : "#476072",
ACCENT : "#548CA8",
BACKGROUND : "#EEEEEE",
}
+14
View File
@@ -0,0 +1,14 @@
export const Dialogs = {
BUTTON: {
OK: "OK",
CANCEL: "Cancel",
SUBMIT: "Submit",
},
ERROR: {
INVALID_CODE: "Invalid Code",
INVALID_CODE_DESC: "The code you entered is invalid. Please try again.",
},
PROMPT: {
ENTER_CODE: "Enter your company code here:",
},
};
+14
View File
@@ -0,0 +1,14 @@
export const Fonts = {
DEFAULT_FONT: 'Arial',
ARIAL : 'Arial',
COMIC_SANS_MS : 'Comic Sans MS',
HELVETICA : 'Helvetica',
TIMES_NEW_ROMAN : 'Times New Roman',
FUTURA : 'Futura',
MONTSERRAT : 'Montserrat',
ROCKWELL : 'Rockwell',
VERDANA : 'Verdana',
CALIBRI : 'Calibri',
BODONI: 'Bodoni',
}
+9
View File
@@ -0,0 +1,9 @@
export const Screens = {
WELCOME : "WelcomeScreen",
CODE_ENTER : "CodeEnterScreen",
BRANCH_PICK : "BranchPickScreen",
ASSIGNED_NUMBER:"AssignedNumberAlert",
TICKET_INFO:"TicketInfoScreen",
TICKET_LIST:"TicketListScreen",
BOTTOM_NAV:"BottomNavigator"
}
+67
View File
@@ -0,0 +1,67 @@
import React from "react";
import { NavigationContainer } from "@react-navigation/native";
import { createStackNavigator } from "@react-navigation/stack";
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import CodeEnterScreen from "../screens/CodeEnterScreen";
import WelcomeScreen from "../screens/WelcomeScreen";
import BranchPickScreen from "../screens/BranchPickScreen"
import TicketListScreen from '../screens/TicketListScreen';
import TicketInfoScreen from '../screens/TicketInfoScreen'
import { Screens } from "../constants/screens";
import AssignedNumberAlert from "../components/AssignedNumberAlert";
import { Ionicons } from '@expo/vector-icons';
const Stack = createStackNavigator();
const Tab = createBottomTabNavigator();
const BottomNavigator = ({ route }) => {
const { details, services, branchID } = route.params;
return (
<Tab.Navigator screenOptions={{headerShown: false }}>
<Tab.Screen
name="Home"
component={WelcomeScreen}
initialParams={{ details, services , branchID }}
options={{
tabBarIcon: ({ color, size }) => (
<Ionicons name="home-outline" size={size} color={color} />
),
}}
/>
<Tab.Screen
name="Moji tiketi"
component={TicketListScreen}
options={{
tabBarIcon: ({ color, size }) => (
<Ionicons name="list-outline" size={size} color={color} />
),
}}
/>
</Tab.Navigator>
);
};
const AppNavigator = () => {
return (
<NavigationContainer>
<Stack.Navigator
initialRouteName={Screens.CODE_ENTER}
screenOptions={{
headerShown: false,
}}
>
<Stack.Screen
name={Screens.CODE_ENTER}
component={CodeEnterScreen}
/>
<Stack.Screen name={Screens.BRANCH_PICK} component={BranchPickScreen} />
<Stack.Screen name={Screens.WELCOME} component={WelcomeScreen} />
<Stack.Screen name={Screens.ASSIGNED_NUMBER} component={AssignedNumberAlert} />
<Stack.Screen name={Screens.TICKET_INFO} component={TicketInfoScreen} />
</Stack.Navigator>
</NavigationContainer>
);
};
export default AppNavigator;
+137
View File
@@ -0,0 +1,137 @@
import React, { useState } from "react";
import {
View,
Text,
FlatList,
TouchableOpacity,
StyleSheet,
Dimensions,
} from "react-native";
import { Screens } from "../constants/screens";
import { getBranchServices } from "../services/fetchData";
import { Colors } from "../constants/colors";
import { Fonts } from "../constants/fonts";
import { Ionicons } from "@expo/vector-icons";
import { useFonts } from "expo-font";
import { Assets } from "../constants/assets";
const windowWidth = Dimensions.get("window").width;
const windowHeight = Dimensions.get("window").height;
export default function BranchPickScreen({
route,
navigation,
}: {
route: any;
navigation: any;
}) {
const { branches } = route.params;
let [fontsLoaded] = useFonts({
[Fonts.ARIAL]: Assets.ARIAL,
[Fonts.TIMES_NEW_ROMAN]: Assets.TIMES_NEW_ROMAN,
[Fonts.VERDANA]: Assets.VERDANA,
[Fonts.HELVETICA]: Assets.HELVETICA,
[Fonts.MONTSERRAT]: Assets.MONTSERRAT,
[Fonts.CALIBRI]: Assets.CALIBRI,
[Fonts.FUTURA]: Assets.FUTURA,
[Fonts.BODONI]: Assets.BODONI,
[Fonts.ROCKWELL]: Assets.ROCKWELL,
[Fonts.COMIC_SANS_MS]: Assets.COMIC_SANS_MS,
});
const renderItem = ({ item }: { item: any }) => (
<TouchableOpacity
style={styles.branchItem}
onPress={() => handlePress(item.id)}
>
<Text style={styles.branchName}>{item.name}</Text>
<View style={styles.iconContainer}>
<Ionicons
name="chevron-forward-circle-outline"
size={windowWidth * 0.1}
color={Colors.ACCENT}
/>
</View>
</TouchableOpacity>
);
async function handlePress(id: any) {
getBranchServices(route.params.code, id)
.then(function (services) {
navigation.navigate(Screens.WELCOME, {
details: route.params.details,
services: services,
branchID: id,
});
})
.catch((error) => {
console.error("Error:", error);
});
}
if (!fontsLoaded) return <Text>Loading...</Text>;
return (
<View style={styles.container}>
<Text style={[styles.title]}>Poslovnice</Text>
<FlatList
data={branches}
renderItem={renderItem}
keyExtractor={(item) => item.id.toString()}
contentContainerStyle={styles.flatListContainer}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: Colors.BACKGROUND,
justifyContent: "center",
paddingTop: windowHeight * 0.1,
},
title: {
fontSize: windowWidth * 0.1,
fontWeight: "bold",
marginBottom: windowHeight * 0.03,
marginLeft: windowWidth * 0.05,
fontFamily: Fonts.ARIAL,
},
flatListContainer: {
flexGrow: 1,
paddingHorizontal: windowWidth * 0.05,
paddingBottom: windowHeight * 0.05,
},
branchItem: {
paddingHorizontal: windowWidth * 0.05,
paddingVertical: windowHeight * 0.06, // Adjust the padding to make the items taller
marginBottom: windowHeight * 0.02,
borderRadius: windowWidth * 0.05,
backgroundColor: "#fff",
shadowColor: Colors.ACCENT,
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.25,
shadowRadius: 3.84,
elevation: 5,
},
branchContent: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
},
branchName: {
flex: 1,
fontSize: windowWidth * 0.08,
fontFamily: Fonts.ARIAL,
textAlign: "center",
},
icon: {
marginLeft: windowWidth * 0.02,
},
iconContainer: {
alignItems: "center",
marginTop: windowHeight * 0.02, // Adjust the margin to position the icon below the branch name
},
});
+127
View File
@@ -0,0 +1,127 @@
import React, { useState } from "react";
import {
SafeAreaView,
StyleSheet,
TextInput,
Pressable,
Text,
Alert,
ActivityIndicator,
View,
} from "react-native";
import { registerRootComponent } from "expo";
import { getCompanyBranches, getCompanyDetails } from "../services/fetchData";
import { Dialogs } from "../constants/dialogs";
import { Colors } from "../constants/colors";
import { Screens } from "../constants/screens";
import { Dimensions } from 'react-native';
const screenWidth = Dimensions.get('window').width;
const buttonWidth = screenWidth * 0.5;
export default function CodeEnterScreen({ navigation }: { navigation: any }) {
const [text, onChangeText] = React.useState("");
const [isLoading, setIsLoading] = useState(false);
function displayInvalidCodeAlert() {
Alert.alert(
Dialogs.ERROR.INVALID_CODE,
Dialogs.ERROR.INVALID_CODE_DESC,
[{ text: Dialogs.BUTTON.OK }],
{ cancelable: false }
);
}
async function handlePress() {
setIsLoading(true);
// await new Promise((resolve) => setTimeout(resolve, 2000));
const code = text;
getCompanyDetails(code)
.then(function (details) {
setIsLoading(false);
getCompanyBranches(code)
.then(function (branches){
navigation.navigate(Screens.BRANCH_PICK, {
details: details, branches: branches, code
});})
})
.catch((error) => {
console.error("Error:", error);
setIsLoading(false);
displayInvalidCodeAlert();
});
}
return (
<SafeAreaView style={styles.area}>
{isLoading && <ActivityIndicator style={styles.loader} size={75} />}
{isLoading && <View style={styles.loadingArea}></View>}
<TextInput
style={styles.input}
onChangeText={(text) => onChangeText(text)}
value={text}
placeholder={Dialogs.PROMPT.ENTER_CODE}
/>
<Pressable
style={({ pressed }) => [
styles.button,
pressed ? styles.buttonPressed : null,
]}
onPress={handlePress}
>
<Text style={styles.text}>{Dialogs.BUTTON.SUBMIT}</Text>
</Pressable>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
loadingArea: {
position: "absolute",
zIndex: 2,
height: "100%",
width: "100%",
backgroundColor: "rgba(255, 255, 255, 0.75)",
},
loader: {
position: "absolute",
zIndex: 100,
},
input: {
height: 40,
margin: 12,
borderWidth: 1,
padding: 10,
textAlign: "center",
width: 220,
},
area: {
justifyContent: "center",
alignItems: "center",
flex: 1,
},
button: {
alignItems: "center",
justifyContent: "center",
paddingVertical: 12,
paddingHorizontal: 32,
borderRadius: 4,
elevation: 3,
width: buttonWidth, // Adjusted dynamically based on screen width
backgroundColor: Colors.ACCENT,
},
buttonPressed: {
backgroundColor: Colors.PRIMARY,
},
text: {
fontSize: 16,
lineHeight: 21,
fontWeight: "bold",
letterSpacing: 0.25,
color: "white",
},
});
registerRootComponent(({ navigation }) => <CodeEnterScreen navigation={navigation} />);
@@ -0,0 +1,64 @@
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, Dimensions } from 'react-native';
import { Fonts } from '../constants/fonts';
import { Colors } from '../constants/colors';
const windowWidth = Dimensions.get('window').width;
const windowHeight = Dimensions.get('window').height;
export default function TicketInfoScreen({ route }: {route: any}) {
const ticket = route.params.ticket
console.log(ticket)
useEffect(() => {
}, []);
return (
<View style={styles.container}>
<Text style={styles.title}>Ticket Details</Text>
{ticket ? (
<View style={styles.detailsContainer}>
<Text style={styles.detailText}>Naziv: {ticket.name}</Text>
<Text style={styles.detailText}>Generisano: {new Date(ticket.date).toLocaleString()}</Text>
<Text style={styles.detailText}>Podići na šalterima:</Text>
{ticket.stations.map((station: string, index: number) => (
<Text key={index} style={styles.stationText}>{"- "+ station}</Text>
))}
</View>
) : (
<Text>Loading...</Text>
)}
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: Colors.BACKGROUND,
justifyContent: 'flex-start',
paddingHorizontal: windowWidth * 0.05,
paddingTop: windowHeight * 0.2,
},
title: {
fontSize: 28,
fontWeight: 'bold',
marginBottom: windowHeight * 0.03,
fontFamily: Fonts.ARIAL,
alignSelf: 'center',
},
detailsContainer: {
marginTop: windowHeight * 0.03,
},
detailText: {
fontSize: 20,
marginBottom: 10,
fontFamily: Fonts.ARIAL,
},
stationText: {
fontSize: 20,
marginBottom: 5,
fontFamily: Fonts.ARIAL,
marginLeft: 10,
}
});
+154
View File
@@ -0,0 +1,154 @@
import React, { useEffect, useState } from 'react';
import { View, Text, FlatList, TouchableOpacity, StyleSheet, Dimensions, ActivityIndicator } from 'react-native';
import { Screens } from '../constants/screens';
import { getTickets } from '../services/fetchData';
import { Colors } from '../constants/colors';
import { Fonts } from '../constants/fonts';
import { Ionicons } from '@expo/vector-icons';
import { getExpoToken } from '../utils/tokenUtils';
const windowWidth = Dimensions.get('window').width;
const windowHeight = Dimensions.get('window').height;
export default function TicketListScreen({ navigation }: { navigation: any }){
const [tickets, setTickets] = useState<any[]>([]);
useEffect(() => {
const fetchTickets = async () => {
try {
const fetchedTickets = await getTickets(getExpoToken());
setTickets(fetchedTickets);
} catch (error) {
console.error('Error fetching tickets:', error);
}
};
fetchTickets();
const intervalId = setInterval(fetchTickets, 10000);
return () => clearInterval(intervalId);
}, []);
const renderItem = ({ item }: { item: any }) => {
return (
<TouchableOpacity
style={styles.ticketItem}
onPress={() => handlePress(item.id)}
>
<Text style={styles.ticketName}>{item.name}</Text>
<View style={styles.stationContainer}>
<Text style={styles.stationTitle}>Šalteri</Text>
{item.stations.map((station: string, index: number) => (
<Text key={index} style={styles.stationList}>{station}</Text>
))}
</View>
<View style={styles.iconContainer}>
<Ionicons name="information-circle-outline" size={windowWidth * 0.1} color={Colors.ACCENT} />
</View>
</TouchableOpacity>
);
};
async function handlePress(id: any) {
let ticket = tickets.find(item => item.id == id)
navigation.navigate(Screens.TICKET_INFO, {
ticket:ticket
})
}
if (tickets.length === 0) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={Colors.PRIMARY} />
</View>
);
} else {
return (
<View style={styles.container}>
<Text style={styles.title}>Tiketi</Text>
<FlatList
data={tickets}
renderItem={renderItem}
keyExtractor={(item) => item.id.toString()}
contentContainerStyle={styles.flatListContainer}
/>
</View>
);
}
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: Colors.BACKGROUND,
justifyContent: 'center',
paddingTop: windowHeight * 0.1,
},
title: {
fontSize: windowWidth * 0.1,
fontWeight: 'bold',
marginBottom: windowHeight * 0.03,
marginLeft: windowWidth * 0.05,
fontFamily: Fonts.ARIAL,
},
flatListContainer: {
flexGrow: 1,
paddingHorizontal: windowWidth * 0.05,
paddingBottom: windowHeight * 0.05,
},
ticketItem: {
paddingHorizontal: windowWidth * 0.05,
paddingVertical: windowHeight * 0.06,
marginBottom: windowHeight * 0.02,
borderRadius: windowWidth * 0.05,
backgroundColor: '#fff',
shadowColor: Colors.ACCENT,
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.25,
shadowRadius: 3.84,
elevation: 5,
},
ticketContent: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
},
ticketName: {
flex: 1,
fontSize: windowWidth * 0.08,
fontFamily: Fonts.ARIAL,
textAlign: 'center',
},
icon: {
marginLeft: windowWidth * 0.02,
},
iconContainer: {
alignItems: 'center',
marginTop: windowHeight * 0.02,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
stationList: {
fontSize: windowWidth * 0.05,
color: 'gray',
marginTop: 5,
},
stationTitle: {
fontSize: windowWidth * 0.06,
fontWeight: 'bold',
marginBottom: 5,
},
stationContainer: {
marginTop: 30,
},
});
+256
View File
@@ -0,0 +1,256 @@
import React, { useEffect, useRef, useState } from "react";
import {
View,
StyleSheet,
Animated,
Text,
FlatList,
TouchableOpacity,
Platform,
Alert,
} from "react-native";
import WelcomeMessage from "../components/WelcomeMessage";
import { useFonts } from "expo-font";
const { Fonts } = require("../constants/fonts");
const { Assets } = require("../constants/assets");
const { Colors } = require("../constants/colors");
import { Ionicons } from "@expo/vector-icons";
import AssignedNumberAlert from "../components/AssignedNumberAlert";
//import * as Notifications from "expo-notifications";
//import * as Device from "expo-device";
import { getExpoToken, setExpoToken } from "../utils/tokenUtils";
import { generateTicket } from "../services/fetchData";
export default function WelcomeScreen({ route }: { route: any }) {
const { details } = route.params;
let { services } = route.params;
let { branchID } = route.params;
let { name, welcomeMessage, font, logoUrl } = details;
/*
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: false,
}),
});
*/
let [fontsLoaded] = useFonts({
[Fonts.ARIAL]: Assets.ARIAL,
[Fonts.TIMES_NEW_ROMAN]: Assets.TIMES_NEW_ROMAN,
[Fonts.VERDANA]: Assets.VERDANA,
[Fonts.HELVETICA]: Assets.HELVETICA,
[Fonts.MONTSERRAT]: Assets.MONTSERRAT,
[Fonts.CALIBRI]: Assets.CALIBRI,
[Fonts.FUTURA]: Assets.FUTURA,
[Fonts.BODONI]: Assets.BODONI,
[Fonts.ROCKWELL]: Assets.ROCKWELL,
[Fonts.COMIC_SANS_MS]: Assets.COMIC_SANS_MS,
});
const fadeAnim = React.useRef(new Animated.Value(0)).current;
useEffect(() => {
Animated.timing(fadeAnim, {
toValue: 1,
duration: 2500,
useNativeDriver: true,
}).start();
}, [fadeAnim]);
/*
useEffect(() => {
getToken();
}, []);
async function getToken() {
try {
await registerForPushNotificationsAsync().then((token) => {
if (token != undefined) setExpoToken(token);
});
} catch (error) {
console.error("Failed to fetch data in TasksScreen:", error);
}
}
*/
const [modalVisible, setModalVisible] = useState(false);
const [ticket, setTicket] = useState(0);
const openModal = () => {
setModalVisible(true);
};
const closeModal = () => {
setModalVisible(false);
};
const renderItem = ({ item }: { item: any }) => (
<TouchableOpacity
style={styles.branchItem}
onPress={() => handlePress(item.id)}
>
<View style={styles.branchInfoContainer}>
<Ionicons
name="information-circle"
size={24}
color={Colors.ACCENT}
style={styles.icon}
/>
<Text style={styles.branchName}>{item.name}</Text>
<Ionicons
name="chevron-forward"
size={24}
color={Colors.ACCENT}
style={styles.icon}
/>
</View>
</TouchableOpacity>
);
async function handlePress(serviceID: any) {
const assignTicket = async () => {
try {
const fetchedTicket = await generateTicket(
getExpoToken(),
branchID,
serviceID
);
setTicket(fetchedTicket.number);
} catch (error) {
console.error("Error fetching tickets:", error);
}
};
assignTicket();
openModal();
}
if (!fontsLoaded) return <Text>Loading...</Text>;
return (
<View style={styles.container}>
<View style={styles.logoContainer}>
<Animated.Image
style={[styles.logo, { opacity: fadeAnim }]}
source={{ uri: logoUrl }}
/>
</View>
<WelcomeMessage name={name} font={font} welcome={welcomeMessage} />
<FlatList
data={services}
renderItem={renderItem}
keyExtractor={(item) => item.id.toString()}
contentContainerStyle={styles.flatListContainer}
/>
<AssignedNumberAlert
visible={modalVisible}
number={ticket}
onClose={closeModal}
/>
</View>
);
}
/*
async function registerForPushNotificationsAsync() {
let token;
if (Platform.OS === "android") {
await Notifications.setNotificationChannelAsync("default", {
name: "default",
importance: Notifications.AndroidImportance.MAX,
vibrationPattern: [0, 250, 250, 250],
lightColor: "#FF231F7C",
});
}
if (Device.isDevice) {
const { status: existingStatus } =
await Notifications.getPermissionsAsync();
let finalStatus = existingStatus;
if (existingStatus !== "granted") {
const { status } = await Notifications.requestPermissionsAsync();
finalStatus = status;
}
if (finalStatus !== "granted") {
alert("Failed to get push token for push notification!");
return;
}
token = (await Notifications.getExpoPushTokenAsync()).data;
} else {
alert("Must use physical device for Push Notifications");
}
return token;
}
*/
export const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: Colors.BACKGROUND,
alignItems: "center",
justifyContent: "center",
},
titleContainer: {
alignSelf: "flex-start",
width: "100%",
},
logoContainer: {
marginBottom: 20, // Add margin to separate logo from title
fontFamily: Fonts.ARIAL,
color: Colors.TEXT_PRIMARY,
},
logo: {
marginTop: 80, // Add margin to separate logo from title
width: "40%", // Adjust the logo size to fit better on all screens
height: undefined, // Allow height to adjust automatically based on width
aspectRatio: 1, // Maintain aspect ratio
resizeMode: "contain",
},
title: {
fontSize: 42,
textAlign: "center",
marginTop: 20,
fontFamily: Fonts.ARIAL,
color: Colors.TEXT_PRIMARY,
},
flatListContainer: {
flexGrow: 1,
paddingHorizontal: 20,
paddingTop: 10, // Add padding to the top of the list to separate from title
},
branchItem: {
marginTop: 10, // Add margin between items
paddingVertical: 15,
paddingHorizontal: 20,
borderRadius: 10,
borderColor: Colors.ACCENT,
borderWidth: 1,
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.25,
shadowRadius: 3.84,
elevation: 5,
width: "100%", // Make items take full width of the screen
},
branchInfoContainer: {
flexDirection: "row", // Arrange items horizontally
alignItems: "center", // Center items vertically
},
branchName: {
fontSize: 30,
fontFamily: Fonts.ARIAL,
color: Colors.TEXT_PRIMARY,
marginLeft: 10, // Add some space between text and icon
},
icon: {
marginRight: 10, // Add some space between icon and text
},
});
+323
View File
@@ -0,0 +1,323 @@
import * as FileSystem from "expo-file-system";
import { Buffer } from "buffer";
import * as Sharing from "expo-sharing";
import { Platform, PermissionsAndroid } from "react-native";
import AsyncStorage from "@react-native-async-storage/async-storage";
const { SERVER_URL } = require("../constants/api");
const { Dialogs } = require("../constants/dialogs");
const { Fonts } = require("../constants/fonts");
const { Assets } = require("../constants/assets");
const { validateCodeFormat } = require("../utils/validation");
interface CompanyData {
name: string;
welcomeMessage: string;
font: string;
logoUrl: string;
}
interface BranchData {
id: string;
name: string;
tellerStations: Array<Int32Array>;
}
interface ServiceData {
id: string;
name: string;
}
interface TicketData {
id: string;
name: string;
date: Date;
number: number;
stations: Array<String>;
}
async function getCompanyDetails(code: string) {
if (!validateCodeFormat(code)) throw new Error(Dialogs.ERROR.INVALID_CODE);
let details: CompanyData = await fetch(
`${SERVER_URL}/api/v1/tenants/${code}`,
{ method: "GET" }
)
.then((response) => {
if (response.status === 200) {
return response.json();
} else {
throw new Error(Dialogs.ERROR.INVALID_CODE);
}
})
.then((data) => {
const companyData: CompanyData = {
name: data.name,
welcomeMessage: data.welcomeMessage,
font: data.font,
logoUrl: data.logo.base64Logo,
};
if (!companyData.font) companyData.font = Fonts.DEFAULT_FONT;
if (!companyData.logoUrl) companyData.logoUrl = Assets.DEFAULT_LOGO;
return companyData;
})
.catch((error) => {
console.error("Error:", error);
throw error;
});
return details;
}
async function getCompanyBranches(code: any) {
const branchList: BranchData[] = [];
let branches: BranchData[] = await fetch(
`${SERVER_URL}/api/v1/branches/${code}`,
{ method: "GET" }
)
.then((response) => {
if (response.status === 200) {
return response.json();
} else {
throw new Error(Dialogs.ERROR.INVALID_CODE);
}
})
.then((data) => {
data.forEach((branch: any) => {
const tellerList: Int32Array[] = [];
branch.tellerStations.forEach((teller: any) => {
tellerList.push(teller);
});
const branchData: BranchData = {
id: branch.id,
name: branch.name,
tellerStations: tellerList,
};
branchList.push(branchData);
});
return branchList;
})
.catch((error) => {
console.error("Error:", error);
throw error;
});
return branches;
}
async function getBranchServices(code: string, id: string) {
const serviceList: ServiceData[] = [];
let services: ServiceData[] = await fetch(
`${SERVER_URL}/api/v1/branches/${code}/${id}/services`,
{ method: "GET" }
)
.then((response) => {
if (response.status === 200) {
return response.json();
} else {
throw new Error(Dialogs.ERROR.INVALID_CODE);
}
})
.then((data) => {
data.forEach((service: any) => {
const serviceData: ServiceData = {
id: service.id,
name: service.name,
};
serviceList.push(serviceData);
});
return serviceList;
})
.catch((error) => {
console.error("Error:", error);
throw error;
});
return services;
}
/*
async function getTickets(token: string) {
const ticketList: TicketData[] = [];
let tickets: TicketData[] = await fetch(
`${SERVER_URL}/tickets/devices/${token}`,
{ method: "GET" }
)
.then((response) => {
if (response.status === 200) {
return response.json();
} else {
throw new Error(Dialogs.ERROR.INVALID_CODE);
}
})
.then((data) => {
let ticket = data;
const stationList: String[] = [];
ticket.stations.forEach((station: any) => {
stationList.push(station.name);
});
const ticketData: TicketData = {
id: ticket.ticket.id,
name: ticket.ticket.branch.name,
date: new Date(ticket.ticket.createdAt),
stations: stationList,
number: ticket.ticket.number,
};
ticketList.push(ticketData);
return ticketList;
})
.catch((error) => {
console.error("Error:", error);
throw error;
});
return tickets;
}
*/
async function generateTicket(
token: string,
branchId: number,
serviceId: number
) {
let tickets: TicketData = await fetch(`${SERVER_URL}/api/v1/tickets`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
branchId: `${branchId}`,
serviceId: `${serviceId}`,
deviceToken: token,
}),
})
.then((response) => {
if (response.status === 200) {
return response.json();
} else {
throw new Error(Dialogs.ERROR.INVALID_CODE);
}
})
.then(async (data) => {
await printTicket(data.ticket.id);
console.log(data.ticket.id);
return data.ticket;
})
.catch((error) => {
console.error("Error:", error);
throw error;
});
return tickets;
}
function printTicket(ticketId: number) {
console.log("Printing ticket...");
fetch(`${SERVER_URL}/api/v1/tickets/${ticketId}/print`, {
method: "GET",
})
.then((response) => {
if (!response.ok) {
throw new Error(
`Failed to fetch PDF: ${response.status} - ${response.statusText}`
);
}
return response.arrayBuffer();
})
.then((pdfByteArray) => {
const filename = `ticket_${ticketId}`;
return savePdfToFile(pdfByteArray, filename);
})
.then((filePath) => {
if (filePath) {
console.log("PDF saved successfully at: ", filePath);
} else {
console.log("Failed to save PDF.");
}
})
.catch((error) => {
console.error("Error printing ticket: ", error);
});
}
async function savePdfToFile(pdfByteArray: ArrayBuffer, filename: string) {
try {
const folderPath = await FileSystem.documentDirectory + "pdfs/";
await FileSystem.makeDirectoryAsync(folderPath, { intermediates: true });
const filePath = folderPath + filename + ".pdf";
const buffer = Buffer.from(pdfByteArray);
const base64String = buffer.toString("base64");
await FileSystem.writeAsStringAsync(filePath, base64String, {
encoding: FileSystem.EncodingType.Base64,
});
saveFile(filePath, filename, "application/pdf");
return filePath;
} catch (error) {
console.error("Error saving PDF: ", error);
return null;
}
}
async function saveFile(uri: string, filename: string, mimetype: string) {
// await AsyncStorage.removeItem("permission");
if (Platform.OS === "android") {
const permission = await AsyncStorage.getItem("permission");
console.log("PERMISSION : " + permission);
if (!permission) {
// Check if permission is already granted
//let x = await Permissions.askAsync(Permissions.MANAGE_EXTERNAL_STORAGE);
const permissions =
await FileSystem.StorageAccessFramework.requestDirectoryPermissionsAsync();
console.log(permissions);
if (permissions.granted)
await AsyncStorage.setItem("permission", permissions.directoryUri);
if (permissions.granted) {
const base64 = await FileSystem.readAsStringAsync(uri, {
encoding: FileSystem.EncodingType.Base64,
});
await FileSystem.StorageAccessFramework.createFileAsync(
permissions.directoryUri,
filename,
mimetype
)
.then(async (uri) => {
await FileSystem.writeAsStringAsync(uri, base64, {
encoding: FileSystem.EncodingType.Base64,
});
})
.catch((e) => console.log(e));
} else {
Sharing.shareAsync(uri);
}
} else {
// Permission already granted
const base64 = await FileSystem.readAsStringAsync(uri, {
encoding: FileSystem.EncodingType.Base64,
});
if (permission !== null)
await FileSystem.StorageAccessFramework.createFileAsync(
permission,
filename,
mimetype
)
.then(async (uri) => {
await FileSystem.writeAsStringAsync(uri, base64, {
encoding: FileSystem.EncodingType.Base64,
});
})
.catch((e) => console.log(e));
}
} else {
Sharing.shareAsync(uri);
}
}
export {
getCompanyDetails,
getBranchServices,
getCompanyBranches,
generateTicket,
};
+16
View File
@@ -0,0 +1,16 @@
const { validateCodeFormat } = require("../utils/validation");
const { Dialogs } = require("../constants/dialogs");
describe('Code validation tests', () => {
test('Valid and invalid code formats', () => {
// Valid code formats
expect(validateCodeFormat('ABCD')).toBe(true);
expect(validateCodeFormat('WXYZ')).toBe(true);
expect(validateCodeFormat('1234')).toBe(true);
// Invalid code formats
expect(validateCodeFormat('ABCD1')).toBe(false);
expect(validateCodeFormat('ABCD!')).toBe(false);
expect(validateCodeFormat('ABC')).toBe(false);
});
});
+9
View File
@@ -0,0 +1,9 @@
let apiToken = '';
export function setExpoToken(token: string) {
apiToken = token;
}
export function getExpoToken() {
return apiToken;
}
+5
View File
@@ -0,0 +1,5 @@
function validateCodeFormat(code: string): boolean {
return /^[A-Z0-9]{4}$/.test(code);
}
export { validateCodeFormat };
+6
View File
@@ -0,0 +1,6 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true
}
}
+21
View File
@@ -0,0 +1,21 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:react/recommended',
'plugin:react/jsx-runtime',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
settings: { react: { version: '18.2' } },
plugins: ['react-refresh'],
rules: {
'react/jsx-no-target-blank': 'off',
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+10
View File
@@ -0,0 +1,10 @@
FROM node:21-alpine AS build
WORKDIR /admin-app
COPY package*.json .
RUN npm install
COPY . .
RUN npm run build
EXPOSE 5001
CMD ["npm", "run", "preview"]
+7
View File
@@ -0,0 +1,7 @@
## Build instructions
- cd **admin-app**
- npm **install**
- npm **run dev**
#### **NOTE:** requires Node 21
Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link href="https://api.fontshare.com/v2/css?f[]=general-sans@200,300,400,500,600,700&display=swap" rel="stylesheet">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BBQMS Admin App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+4803
View File
File diff suppressed because it is too large Load Diff
+36
View File
@@ -0,0 +1,36 @@
{
"name": "admin-app",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --port 5000",
"build": "vite build",
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@react-oauth/google": "^0.12.1",
"bootstrap": "^5.3.3",
"bootstrap-icons": "^1.11.3",
"formik": "^2.4.5",
"react": "^18.2.0",
"react-bootstrap": "^2.10.2",
"react-dom": "^18.2.0",
"react-icons": "^5.0.1",
"react-router-dom": "^6.22.3",
"rsuite": "^5.59.0",
"validator": "^13.11.0",
"yup": "^1.4.0"
},
"devDependencies": {
"@types/react": "^18.2.64",
"@types/react-dom": "^18.2.21",
"@vitejs/plugin-react": "^4.2.1",
"eslint": "^8.57.0",
"eslint-plugin-react": "^7.34.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"vite": "^5.1.6"
}
}
+3645
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
.background-color-app{
background-color: whitesmoke;
}
+200
View File
@@ -0,0 +1,200 @@
import React, { useEffect, useState } from 'react';
import { Route, Routes } from 'react-router-dom';
import Header from './components/Header/Header.jsx';
import { SERVER_URL } from './constants.js';
import { UserContext } from './context/UserContext.jsx';
import { fetchData } from './fetching/Fetch.js';
import AuthGuard from './components/AuthGuard/AuthGuard';
import LoginScreen from './pages/LoginScreen/LoginScreen';
import CompanyInfoUpdate from './pages/CompanyInfoUpdate/CompanyInfoUpdate';
import HomePage from './pages/HomePage/HomePage';
import NotFound from './pages/NotFound/NotFound.jsx';
import CanAccess from './components/CanAccess/CanAccess';
import HomePageCard from './components/HomePageCard/HomePageCard';
import AdminProfile from './pages/AdminProfile/AdminProfile';
import LoginAuth from './components/LoginAuth/LoginAuth';
import ManageAdmins from './pages/AdminManagingScreen/AdminManagingScreen';
import ManageServices from './pages/ManageServices/ManageServices';
import ManageBranches from './pages/ManageBranchesScreen/ManageBranchesScreen';
import ManageGroups from './pages/ManageGroupsScreen/ManageGroupsScreen';
import ManageStations from './pages/ManageStationScreen/ManageStationScreen';
import ManageDisplays from './pages/ManageDisplays/ManageDisplays';
import ManageUsers from './pages/UserManagingScreen/UserManagingScreen';
import ViewQueues from './pages/ViewBranchQueues/ViewBranchQueues';
import { ROLES } from './constants.js';
export default function App() {
const [user, setUser] = useState();
/*
Kada se logiramo, ako vec postoji token u localStorage, provjerimo da li je validan (nije istekao)
Ako je validan, ulogujemo usera, ako nije ocistimo storage od starih podataka
*/
useEffect(() => {
const token = localStorage.getItem('token');
if (token) {
const url = `${ SERVER_URL }/api/v1/auth`;
fetchData(url, 'GET')
.then(({ data, success }) => {
if (success) {
setUser(JSON.parse(localStorage.getItem('userData')));
} else {
localStorage.removeItem('token');
localStorage.removeItem('userData');
}
});
}
}, []);
return (
<>
<UserContext.Provider value={ { user, setUser } }>
<Header />
<Routes>
<Route exact path="/:tenantCode/manage/displays" element={
<AuthGuard roles={ [ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN] }>
<ManageDisplays />
</AuthGuard> } />
<Route exact path="/:tenantCode/manage/stations" element={
<AuthGuard roles={ [ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN] }>
<ManageStations />
</AuthGuard> } />
<Route exact path="/:tenantCode/manage/groups" element={
<AuthGuard roles={ [ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN] }>
<ManageGroups />
</AuthGuard> } />
<Route exact path="/:tenantCode/manage/branches" element={
<AuthGuard roles={ [ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN] }>
<ManageBranches />
</AuthGuard> } />
<Route exact path="/:tenantCode/companydetails"
element={
<AuthGuard roles={ [ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN] }>
<CompanyInfoUpdate />
</AuthGuard>
}
/>
<Route exact path="/:tenantCode/manage/services" element={
<AuthGuard roles={ [ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN] }>
<ManageServices />
</AuthGuard> }
/>
<Route exact path="/:tenantCode/manage/users" element={
<AuthGuard roles={ [ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN] }>
<ManageUsers />
</AuthGuard>
} />
<Route exact path="/login" element={ <LoginScreen /> } />
<Route exact path="/:tenantCode/manage/admins" element={
<AuthGuard roles={ [ROLES.ROLE_SUPER_ADMIN] }>
<ManageAdmins />
</AuthGuard> } />
<Route exact path="/profile" element={
<AuthGuard roles={ [ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN] }>
<AdminProfile />
</AuthGuard> } />
<Route exact path="/" element={ <LoginScreen /> } />
<Route exact path="/loginauth"
element={
<AuthGuard roles={ [ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN] }>
<LoginAuth />
</AuthGuard>
}
/>
<Route exact path="/:tenantCode/queues" element={
<AuthGuard roles={ [ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN] }>
<ViewQueues />
</AuthGuard> } />
<Route exact path="/:tenantCode/home" element={
<AuthGuard roles={ [ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN] }>
<HomePage></HomePage>
<CanAccess roles={ [ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN] }>
<>
{ user && (
<>
<HomePageCard
title="Manage displays"
backgroundColor="var(--dark-blue)"
buttonColor="var(--light-blue)"
url={ `/${ user.tenantCode }/manage/displays` }
/>
<HomePageCard
title="Manage groups"
backgroundColor="var(--light-blue)"
buttonColor="var(--dark-blue)"
url={ `/${ user.tenantCode }/manage/groups` }
/>
<HomePageCard
title="Manage branches"
backgroundColor="var(--dark-blue)"
buttonColor="var(--light-blue)"
url={ `/${ user.tenantCode }/manage/branches` }
/>
<HomePageCard
title="Manage services"
backgroundColor="var(--light-blue)"
buttonColor="var(--dark-blue)"
url={ `/${ user.tenantCode }/manage/services` }
/>
<HomePageCard
title="Manage teller stations"
backgroundColor="var(--light-blue)"
buttonColor="var(--dark-blue)"
url={ `/${ user.tenantCode }/manage/stations` }
/>
<HomePageCard
title="Manage company details"
backgroundColor="var(--dark-blue)"
buttonColor="var(--light-blue)"
url={ `/${ user.tenantCode }/companydetails` }
/>
<HomePageCard
title="View queues"
backgroundColor="var(--light-blue)"
buttonColor="var(--dark-blue)"
url={ `/${ user.tenantCode }/queues` }
/>
</>
) }
</>
</CanAccess>
<CanAccess roles={ [ROLES.ROLE_SUPER_ADMIN] }>
<>
{ user && (
<>
<HomePageCard title="Manage administrators"
backgroundColor="var(--dark-blue)"
buttonColor="var(--light-blue)"
url={ `/${ user.tenantCode }/manage/admins` }></HomePageCard>
<HomePageCard
title="Manage users"
backgroundColor="var(--light-blue)"
buttonColor="var(--dark-blue)"
url={ `/${ user.tenantCode }/manage/users` }
/>
</>
) }
</>
</CanAccess>
<CanAccess roles={ [ROLES.ROLE_BRANCH_ADMIN] }>
{ user && (
<HomePageCard
title="Manage users"
backgroundColor="var(--dark-blue)"
buttonColor="var(--light-blue)"
url={ `/${ user.tenantCode }/manage/users` }
/>
) }
</CanAccess>
</AuthGuard>
} />
<Route path="*" element={ <NotFound /> } />
</Routes>
</UserContext.Provider>
</>
);
}
@@ -0,0 +1,30 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
export default function AuthGuard({ children, roles }) {
const navigate = useNavigate();
useEffect(() => {
const storedUserData = localStorage.getItem('userData');
const user = storedUserData ? JSON.parse(storedUserData) : null;
if (!user) {
navigate('/login');
return;
}
const hasNecessaryRole = roles.some(role => user.roles.find(userRole => userRole === role));
if (!hasNecessaryRole) {
navigate('/');
return;
}
}, []);
return (
<>
{ children }
</>
);
}
@@ -0,0 +1,10 @@
import { useContext } from 'react';
import { UserContext } from '../../context/UserContext.jsx';
export default function CanAccess( {children, roles} ){
const {user, setUser} = useContext(UserContext);
const hasRole = user && user.roles && user.roles.some(userRole => roles.find(role => role === userRole));
return hasRole ? children : null;
}
@@ -0,0 +1,59 @@
header.main-header {
display: flex;
justify-content: space-around;
align-items: center;
margin: 0 0 0 -2%;
background-color: #d3e2f8;
width: 101vw;
}
.header-logo {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
margin-right: auto;
margin-left: 2%;
}
.header-logo-png {
width: 200px;
height: 60px;
}
.header-logout-btn {
background-color: var(--blue);
color: white;
justify-content: center; /* Center content horizontally */
align-items: center;
border-radius: 6px;
border: none;
box-shadow: 3px 8px 10px 0 rgba(0,0,0,0.2);
cursor: pointer;
display: flex;
height: 40px;
width: 130px;
font-size: 20px;
margin-bottom: 2px;
}
.header-logout-btn:hover {
background-color: var(--light-blue);
transition-duration: 300ms;
}
.header-profile {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
margin-left: 20px;
margin-right: 2%;
margin-bottom: 0.5%;
}
.header-profile-png {
width: 46px;
height: 46px;
}
@@ -0,0 +1,62 @@
import { useContext } from 'react';
import { Button } from 'react-bootstrap';
import { useNavigate } from 'react-router-dom';
import { lastPathPart } from '../../utils/StringUtils.js';
import profileImage from '../../../assets/profile-user.png'
import './Header.css';
import { UserContext } from '../../context/UserContext.jsx';
export default function Header() {
const navigate = useNavigate();
const { user, setUser } = useContext(UserContext);
function handleLogout() {
localStorage.removeItem('userData');
localStorage.removeItem('token');
setUser(null);
navigate('/');
}
const path = window.location.pathname;
const showBackButton = !!user
&& '/' !== path
&& '/login' !== path
&& 'home' !== lastPathPart(window.location.href);
const goHome = () => {
if (user?.tenantCode) {
navigate(`/${user.tenantCode}/home`);
} else {
navigate('/');
}
};
return (
<>
<header className="main-header">
<h2 className="header-logo" onClick={ goHome }>BBQMS</h2>
<div className="header-logout">
{ !!user && (
<button className="header-logout-btn" onClick={ handleLogout }>
Logout
</button>
) }
</div>
<div className="header-profile" onClick={ () => navigate('/profile') }>
<img src={ profileImage } className="header-profile-png" alt="Profile image" />
</div>
</header>
{ showBackButton && (
<Button variant="secondary"
className="mt-2 px-4"
onClick={ goHome }>
Back
</Button>
) }
</>
);
}
@@ -0,0 +1,31 @@
.button-hp{
height: 80%;
display: inline-block;
margin-top: 50%;
box-shadow: 3px 6px 10px 0 rgba(0,0,0,0.2);
cursor: pointer;
}
.card-hp{
width: 14%;
border-radius: 10px;
float: left;
margin-right: 4%;
margin-left: 6%;
margin-top: 6%;
}
.card-title-hp{
text-align: center;
color: #334257;
height: 10%;
max-height: 10%;
}
.card-text-hp{
text-align: center;
color: #334257;
}
.button-container-hp{
text-align: center;
}
@@ -0,0 +1,21 @@
import React from "react";
import { useNavigate, useParams } from 'react-router-dom';
import './HomePageCard.css'
export default function HomePageCard( {title, backgroundColor, buttonColor, url} ) {
const navigate = useNavigate();
return (
<div>
<div className="card card-hp" style={{border: '1px solid #334257', backgroundColor: backgroundColor, height: '200px'}}>
<div className="card-body">
<h5 className="card-title card-title-hp" style={{color: 'white'}}>{title}</h5>
<p className="card-text card-text-hp" style={{color: 'white'}}></p>
<div className="button-container-hp">
<button className="btn btn-primary button-hp"
style={{backgroundColor: buttonColor}} onClick ={ () => navigate(url) } >Open</button>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,21 @@
import React from "react";
import './HomePageCard.css'
export default function HomePageCardLight() {
return (
<div>
<div className="card card-hp" style={{border: '1px solid #334257', backgroundColor: '#548CA8'}}>
<div className="card-body">
<h5 className="card-title card-title-hp" style={{color: 'white'}}>Card title</h5>
<p className="card-text card-text-hp" style={{color: 'white'}}>Some quick example text to build on
the card title and make up the bulk of
the card's content.</p>
<div className="button-container-hp">
<a href="#" className="btn btn-primary button-hp"
style={{backgroundColor: '#476072', border: '1px solid #548CA8'}}>Go somewhere</a>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,53 @@
#auth-container *{
box-sizing: border-box;
}
#auth-container{
font-family: "Poppins", sans-serif;
min-height: 100vh;
display: grid;
place-items: center;
}
#authForm {
border: 1px solid black;
padding: 40px;
width: 1000px;
background-color: white;
}
.inputs {
display: grid;
gap: 20px;
grid-template-columns: repeat(6, 1fr);
margin-bottom: 20px;
}
.inputs > * {
border: 1px solid black;
width: 100%;
padding: 40px;
text-align: center;
font-size: 40px;
line-height: 1;
}
.button-auth {
width: 100%;
margin-top: 20px;
padding: 24px;
background-color: #548CA8;
border: none;
color: white;
font-size: 32px;
font-weight: bold;
border-radius: 5px;
cursor: pointer;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: background-color 0.3s ease;
}
.button-auth:hover {
background-color: #334257;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
}
@@ -0,0 +1,243 @@
import React, { useEffect, useRef, useReducer, useContext } from "react";
import "./LoginAuth.css";
import { SERVER_URL } from "../../constants.js";
import { fetchData } from '../../fetching/Fetch.js';
import { UserContext } from '../../context/UserContext.jsx';
import { useNavigate } from "react-router-dom";
function doSubmit(submittedValues) {
console.log(`Submitted: ${submittedValues.join("")}`);
return new Promise((resolve) => {
setTimeout(() => {
resolve();
}, 1500);
});
}
function clampIndex(index) {
if (index > 6) {
return 6;
} else if (index < 0) {
return 0;
} else {
return index;
}
}
function reducer(state, action) {
switch (action.type) {
case "INPUT":
return {
...state,
inputValues: [
...state.inputValues.slice(0, action.payload.index),
action.payload.value,
...state.inputValues.slice(action.payload.index + 1)
],
focusedIndex: clampIndex(state.focusedIndex + 1)
};
case "BACK":
return {
...state,
focusedIndex: clampIndex(state.focusedIndex - 1)
};
case "PASTE":
return {
...state,
inputValues: state.inputValues.map(
(_, index) => action.payload.pastedValue[index] || ""
)
};
case "FOCUS":
return {
...state,
focusedIndex: action.payload.focusedIndex
};
case "VERIFY":
return {
...state,
status: "pending"
};
case "VERIFY_SUCCESS":
return {
...state,
status: "idle"
};
case "RESET_INPUTS":
return {
...state,
inputValues: Array(6).fill(""),
focusedIndex: 0,
status: "idle"
};
default:
throw new Error("unknown action");
}
}
const initialState = {
inputValues: Array(6).fill(""),
focusedIndex: 0,
status: "idle"
};
export default function LoginAuth() {
const { user, setUser } = useContext(UserContext);
const [{ inputValues, focusedIndex, status }, dispatch] = useReducer(
reducer,
initialState
);
function handleInput(index, value) {
dispatch({ type: "INPUT", payload: { index, value } });
}
function handleBack() {
dispatch({ type: "BACK" });
}
function handlePaste(pastedValue) {
dispatch({ type: "PASTE", payload: { pastedValue } });
if (pastedValue.length === 6) {
dispatch({ type: "VERIFY" });
doSubmit(pastedValue.split("")).then(() =>
dispatch({ type: "VERIFY_SUCCESS" })
);
}
}
function handleFocus(focusedIndex) {
dispatch({ type: "FOCUS", payload: { focusedIndex } });
}
async function handleSubmit(e) {
//e.preventDefault();
dispatch({ type: "VERIFY" });
try {
const storedUserData = localStorage.getItem('userData');
const userData = storedUserData ? JSON.parse(storedUserData) : null;
if (!userData) {
throw new Error("Email not found in localStorage");
}
const url = `${ SERVER_URL }/api/v1/auth/tfa`;
const { data, success } = await fetchData(url, 'POST', {
code: inputValues.join(""),
email: userData.email
});
if (success) {
localStorage.setItem('token', data.token);
setUser(data.userData);
navigate(`/${ data.userData.tenantCode }/home`);
} else {
throw new Error("Code could not be verified. It is incorrect or expired.");
}
} catch (error) {
alert("Code could not be verified. It is incorrect or expired.");
resetInputs();
}
}
function resetInputs() {
dispatch({ type: "RESET_INPUTS" });
}
let navigate = useNavigate();
function routeChange() {
handleSubmit({});
}
return (
<div id="auth-container">
<form id="authForm" onSubmit={handleSubmit}>
<div className="inputs">
{inputValues.map((value, index) => {
return (
<Input
key={index}
index={index}
value={value}
onChange={handleInput}
onBackspace={handleBack}
onPaste={handlePaste}
isFocused={index === focusedIndex}
onFocus={handleFocus}
isDisabled={status === "pending"}
/>
);
})}
</div>
<button className="button-auth" disabled={status === "pending"} onClick={routeChange}>
{status === "pending" ? "VERIFYING..." : "VERIFY"}
</button>
</form>
</div>
);
}
function Input({
index,
value,
onChange,
onPaste,
onBackspace,
isFocused,
onFocus,
isDisabled
}) {
const ref = useRef();
useEffect(() => {
requestAnimationFrame(() => {
if (ref.current !== document.activeElement && isFocused) {
ref.current.focus();
}
});
}, [isFocused]);
function handleChange(e) {
onChange(index, e.target.value);
}
function handlePaste(e) {
onPaste(e.clipboardData.getData("text"));
}
function handleKeyDown(e) {
if (e.key === "Backspace") {
onBackspace();
}
}
function handleFocus(e) {
e.target.setSelectionRange(0, 1);
onFocus(index);
}
return (
<input
ref={ref}
type="text"
value={value}
onChange={handleChange}
onPaste={handlePaste}
onKeyDown={handleKeyDown}
maxLength="1"
onFocus={handleFocus}
disabled={isDisabled}
/>
);
}
@@ -0,0 +1,82 @@
#login-form {
position: relative;
width: 420px;
max-width: 100%;
margin: 150px auto 50px;
background-color: #334257;
border-radius: 10px;
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1);
font-family: "Poppins", sans-serif;
}
#login-form h1 {
text-align: center;
margin: 0;
padding: 20px 0;
font-size: 28px;
font-weight: bold;
color: white;
}
#login-form form {
padding: 20px;
background-color: white;
border-radius: 10px;
font-family: "Poppins", sans-serif;
}
#login-form form label {
display: block;
margin-bottom: 8px;
font-size: 14px;
color: gray;
font-family: "Poppins", sans-serif;
}
#login-form form input[type="text"],
#login-form form input[type="password"] {
width: 100%;
padding: 12px;
border: 1px solid lightgray;
border-radius: 5px;
font-size: 16px;
box-sizing: border-box;
margin-bottom: 20px;
}
#login-form form input[type="submit"] {
width: 100%;
margin-top: 10px;
padding: 12px;
background-color: #548CA8;
border: none;
color: white;
font-size: 16px;
font-weight: bold;
border-radius: 5px;
cursor: pointer;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: background-color 0.3s ease;
}
#login-form form input[type="submit"]:hover {
background-color: #334257;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
}
#registration{
text-align: center;
}
.form-group {
margin-bottom: 20px;
position: relative;
}
.error {
color: red;
font-size: 12px;
position: absolute;
bottom: -20px;
left: 0;
width: 100%;
}
@@ -0,0 +1,144 @@
import { GoogleLogin } from '@react-oauth/google';
import React, { useState } from "react";
import validator from "validator";
import "./LoginForm.css";
import LoginAuth from "../LoginAuth/LoginAuth";
import { Route, Routes, useNavigate, Link } from "react-router-dom";
import { SERVER_URL } from "../../constants.js";
const LoginForm = () => {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [isSubmitted, setIsSubmitted] = useState(false);
const navigate = useNavigate();
async function handleGoogleLogin(credentialResponse) {
const response = await fetch(`${SERVER_URL}/api/v1/auth/login/oauth2/google`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
googleToken: credentialResponse.credential
})
});
if (!response.ok) {
setError("Error while trying to log in with Google.");
return;
}
const data = await response.json();
localStorage.setItem('userData', JSON.stringify(data));
navigate('/');
}
const handleSubmit = async (event) => {
event.preventDefault();
console.log("username: " + username);
console.log("password: " + password);
if (!username.trim()) {
setError("Username is required");
return;
}
if (!validator.isEmail(username) && !validator.isMobilePhone(username, "any")) {
setError("Invalid username format. Please enter a valid email or phone number.");
return;
}
if (!password.trim()) {
setError("Password is required.");
return;
}
try {
const response = await fetch(`${SERVER_URL}/api/v1/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: username,
password: password
}),
});
const data = await response.json();
localStorage.setItem('userData', JSON.stringify(data));
if (response.ok) {
setIsSubmitted(true);
navigate('/');
} else if (response.status === 403) {
setError("Your credentials are incorrect.");
}
} catch (error) {
console.error('Error:', error);
setError("An error occurred. Please try again.");
}
};
const handleUsernameChange = (event) => {
setUsername(event.target.value);
setError("");
};
const handlePasswordChange = (event) => {
setPassword(event.target.value);
setError("");
};
if (isSubmitted) {
return (
<Routes>
<Route path='/' element={<LoginAuth />} />
</Routes>
)
}
return (
<div id="login-form">
<h1>LOGIN</h1>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="username">Email or phone number:</label>
<input
type="text"
id="username"
name="username"
value={username}
onChange={handleUsernameChange}
/>
{error && (error.includes("Username") || error.includes("Invalid")) && <p className="error">{error}</p>}
{error && (error.includes("credentials")) && <p className="error">{error}</p>}
</div>
<div className="form-group">
<label htmlFor="password">Password:</label>
<input
type="password"
id="password"
name="password"
value={password}
onChange={handlePasswordChange}
/>
{error && error.includes("Password") && <p className="error">{error}</p>}
</div>
<input type="submit" value="Submit" />
<p id="registration">
Not registered? <Link to="/registration">Create an account</Link>
</p>
<div style={{
display: 'flex',
justifyContent: 'center'
}}>
</div>
</form>
</div>
);
};
export default LoginForm;
@@ -0,0 +1,66 @@
.auth-container-reg *{
box-sizing: border-box;
}
.auth-container-reg{
font-family: "Poppins", sans-serif;
min-height: 100vh;
display: grid;
place-items: center;
}
.authForm-reg {
border: 1px solid black;
padding: 0px 40px 40px 40px;
width: 1000px;
background-color: white;
}
.QRCodeContainer {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
margin-bottom: 20px;
}
.QRCodeContainer h1 {
margin-bottom: 30px;
}
.inputs-reg {
display: grid;
gap: 20px;
grid-template-columns: repeat(6, 1fr);
margin-bottom: 20px;
}
.inputs-reg > * {
border: 1px solid black;
width: 100%;
padding: 40px;
text-align: center;
font-size: 40px;
line-height: 1;
}
button {
width: 100%;
margin-top: 20px;
padding: 24px;
background-color: #548CA8;
border: none;
color: white;
font-size: 32px;
font-weight: bold;
border-radius: 5px;
cursor: pointer;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: background-color 0.3s ease;
}
button:hover {
background-color: #334257;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
}
@@ -0,0 +1,236 @@
import React, { useEffect, useRef, useReducer, useState } from "react";
import "./RegistrationAuth.css";
import { SERVER_URL } from "../../constants";
function doSubmit(submittedValues) {
console.log(`Submitted: ${submittedValues.join("")}`);
return new Promise((resolve) => {
setTimeout(() => {
resolve();
}, 1500);
});
}
function clampIndex(index) {
if (index > 6) {
return 6;
} else if (index < 0) {
return 0;
} else {
return index;
}
}
function reducer(state, action) {
switch (action.type) {
case "INPUT":
return {
...state,
inputValues: [
...state.inputValues.slice(0, action.payload.index),
action.payload.value,
...state.inputValues.slice(action.payload.index + 1)
],
focusedIndex: clampIndex(state.focusedIndex + 1)
};
case "BACK":
return {
...state,
focusedIndex: clampIndex(state.focusedIndex - 1)
};
case "PASTE":
return {
...state,
inputValues: state.inputValues.map(
(_, index) => action.payload.pastedValue[index] || ""
)
};
case "FOCUS":
return {
...state,
focusedIndex: action.payload.focusedIndex
};
case "VERIFY":
return {
...state,
status: "pending"
};
case "VERIFY_SUCCESS":
return {
...state,
status: "idle"
};
case "RESET_INPUTS":
return {
...state,
inputValues: Array(6).fill(""),
focusedIndex: 0,
status: "idle"
};
default:
throw new Error("unknown action");
}
}
const initialState = {
inputValues: Array(6).fill(""),
focusedIndex: 0,
status: "idle"
};
export default function RefistrationAuth( {qrCode, email} ) {
const [{ inputValues, focusedIndex, status }, dispatch] = useReducer(
reducer,
initialState
);
function handleInput(index, value) {
dispatch({ type: "INPUT", payload: { index, value } });
}
function handleBack() {
dispatch({ type: "BACK" });
}
function handlePaste(pastedValue) {
dispatch({ type: "PASTE", payload: { pastedValue } });
if (pastedValue.length === 6) {
dispatch({ type: "VERIFY" });
doSubmit(pastedValue.split("")).then(() =>
dispatch({ type: "VERIFY_SUCCESS" })
);
}
}
function handleFocus(focusedIndex) {
dispatch({ type: "FOCUS", payload: { focusedIndex } });
}
function handleSubmit(e) {
e.preventDefault();
dispatch({ type: "VERIFY" });
doSubmit(inputValues).then(() => dispatch({ type: "VERIFY_SUCCESS" }));
}
const checkInputValues = async () =>{
const enteredValues = inputValues.join("");
const requestBody = {
code: enteredValues,
email: email
}
try{
const response = await fetch(SERVER_URL + '/api/v1/auth/tfa', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(requestBody)
});
if (response.ok) {
dispatch({ type: "VERIFY_SUCCESS" });
} else {
throw new Error("Code could not be verified. It is incorrect or expired.");
}
} catch (error) {
alert("Code could not be verified. It is incorrect.");
resetInputs();
}
}
function resetInputs() {
dispatch({ type: "RESET_INPUTS" });
}
return (
<div className="auth-container-reg">
<form className="authForm-reg" onSubmit={handleSubmit}>
<div className="QRCodeContainer">
<h1>Scan QR code</h1>
<img src={qrCode} height='300px'></img>
</div>
<div className="inputs-reg">
{inputValues.map((value, index) => {
return (
<Input
key={index}
index={index}
value={value}
onChange={handleInput}
onBackspace={handleBack}
onPaste={handlePaste}
isFocused={index === focusedIndex}
onFocus={handleFocus}
isDisabled={status === "pending"}
/>
);
})}
</div>
<button onClick={checkInputValues} disabled={status === "pending"}>
{status === "pending" ? "VERIFYING..." : "VERIFY"}
</button>
</form>
</div>
);
}
function Input({
index,
value,
onChange,
onPaste,
onBackspace,
isFocused,
onFocus,
isDisabled
}) {
const ref = useRef();
useEffect(() => {
requestAnimationFrame(() => {
if (ref.current !== document.activeElement && isFocused) {
ref.current.focus();
}
});
}, [isFocused]);
function handleChange(e) {
onChange(index, e.target.value);
}
function handlePaste(e) {
onPaste(e.clipboardData.getData("text"));
}
function handleKeyDown(e) {
if (e.key === "Backspace") {
onBackspace();
}
}
function handleFocus(e) {
e.target.setSelectionRange(0, 1);
onFocus(index);
}
return (
<input
ref={ref}
type="text"
value={value}
onChange={handleChange}
onPaste={handlePaste}
onKeyDown={handleKeyDown}
maxLength="1"
onFocus={handleFocus}
disabled={isDisabled}
/>
);
}
@@ -0,0 +1,75 @@
.login-form-reg {
position: relative;
width: 420px;
max-width: 100%;
margin: 150px auto 50px;
background-color: #334257;
border-radius: 10px;
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1);
font-family: "Poppins", sans-serif;
}
.login-form-reg h1 {
text-align: center;
margin: 0;
padding: 20px 0;
font-size: 28px;
font-weight: bold;
color: white;
}
.login-form-reg form {
padding: 20px;
background-color: white;
border-radius: 10px;
font-family: "Poppins", sans-serif;
}
.login-form-reg form label {
display: block;
margin-bottom: 8px;
font-size: 14px;
color: gray;
font-family: "Poppins", sans-serif;
}
.login-form-reg form input[type="text"],
.login-form-reg form input[type="password"] {
width: 100%;
padding: 12px;
border: 1px solid lightgray;
border-radius: 5px;
font-size: 16px;
box-sizing: border-box;
margin-bottom: 20px;
}
.login-form-reg form input[type="submit"] {
width: 100%;
margin-top: 10px;
padding: 12px;
background-color: #548CA8;
border: none;
color: white;
font-size: 16px;
font-weight: bold;
border-radius: 5px;
cursor: pointer;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: background-color 0.3s ease;
}
.login-form-reg form input[type="submit"]:hover {
background-color: #334257;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
}
.login{
text-align: center;
}
.error-reg{
color: red;
font-size: 12px;
margin-top: 5px;
}
@@ -0,0 +1,104 @@
import React, { useState } from 'react';
import { Route, Routes, useNavigate, Link } from "react-router-dom";
import { useFormik } from 'formik';
import * as yup from 'yup';
import "./RegistrationForm.css";
import RegistrationAuth from '../RegistrationAuth/RegistrationAuth';
import { SERVER_URL } from '../../constants';
const userSchema = yup.object().shape({
email: yup.string().email("Please enter a valid email").required("Email is required"),
password: yup.string().min(4).max(10).required("Password is required")
});
export default function RegistrationForm() {
const navigate = useNavigate();
const [nextPage, setNextPage] = useState(false);
const [qrCode, setQrCode] = useState("");
const [email, setEmail] = useState("");
const formik = useFormik({
initialValues: {
email: '',
password: ''
},
validationSchema: userSchema,
onSubmit: async (values, { setFieldError }) => {
try {
const isValid = await userSchema.isValid(values);
if (isValid) {
const response = await fetch(SERVER_URL + '/api/v1/auth/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
email: values.email,
password: values.password,
phone_number: "00000"
})
});
if (response.ok) {
const body = await response.json();
const responseBody = await fetch(SERVER_URL + `/api/v1/auth/tfa?email=${body.email}`);
const responseData = await responseBody.json();
setQrCode(responseData.qrCode);
setEmail(body.email);
setNextPage(true);
} else if (response.status === 400) {
setFieldError('email', 'Email already in use');
} else {
console.error('Failed to register:', response.statusText);
}
}
} catch (error) {
console.error('Error fetching data:', error);
}
}
});
if (nextPage) {
return (
<Routes>
<Route path='*' element={<RegistrationAuth qrCode={qrCode} email={email} />} />
</Routes>
);
}
return (
<div className="login-form-reg">
<h1>REGISTER ACCOUNT</h1>
<form onSubmit={formik.handleSubmit}>
<label htmlFor="email">Email or phone number:</label>
<input
id="email"
name="email"
type="text"
onChange={formik.handleChange}
onBlur={formik.handleBlur}
value={formik.values.email}
/>
{formik.touched.email && formik.errors.email ? (
<p className="error-reg">{formik.errors.email}</p>
) : null}
<label htmlFor="password">Password:</label>
<input
id="password"
name="password"
type="password"
onChange={formik.handleChange}
onBlur={formik.handleBlur}
value={formik.values.password}
/>
{formik.touched.password && formik.errors.password ? (
<p className="error-reg">{formik.errors.password}</p>
) : null}
<input type="submit" value="Create new account"/>
<p className="login">
Already have an account? <Link to="/login">Login</Link>
</p>
</form>
</div>
);
}
@@ -0,0 +1,19 @@
import { useSortContext } from '../../context/SortContext.jsx';
import { useSortSearchState } from '../../hooks/sortParamsHooks.jsx';
export function SortableHeader({ columnName, children }) {
const { sort, updateSort } = useSortSearchState(columnName);
const sortContext = useSortContext();
function handleSort() {
const newSort = updateSort();
sortContext.onSort(newSort);
}
return (
<th style={{ cursor: 'pointer' }}
onClick={ handleSort }>
{ children }
</th>
)
}
@@ -0,0 +1,72 @@
import { useState } from 'react';
import { createClassName } from '../../utils/StringUtils.js'
export function TimePicker({ title, onChange, className, defaultHour, defaultMinute }) {
const [ hours, setHours ] = useState(defaultHour ?? '');
const [ minutes, setMinutes ] = useState(defaultMinute ?? '');
function handleChange(hours, minutes) {
onChange({
hour: hours && hours !== '' ? parseInt(hours) : defaultHour ?? 0,
minutes: minutes && minutes !== '' ? parseInt(minutes) : defaultMinute ?? 0
});
}
function handleHourChange(value) {
setHours(value);
handleChange(value, minutes);
}
function handleMinuteChange(value) {
setMinutes(value);
handleChange(hours, value);
}
return (
<div className={ createClassName([ 'd-inline-flex flex-column gap-1', className ]) }>
<div>{ title }</div>
<div className="d-inline-flex gap-2 align-items-center">
<TimePart value={ hours }
placeholder="HH"
min={ 0 }
max={ 23 }
onChange={ handleHourChange } />
:
<TimePart value={ minutes }
placeholder="MM"
min={ 0 }
max={ 59 }
onChange={ handleMinuteChange } />
</div>
</div>
)
}
function TimePart({ value, placeholder, onChange, min, max }) {
function handleChange(newValueString) {
if (newValueString !== '') {
if (isNaN(newValueString)) {
onChange('');
return;
}
const newValue = parseInt(newValueString);
if (newValue <= max && newValue >= min) {
onChange(newValue)
}
} else {
onChange('');
}
}
return (
<input className="border py-1 px-2 text-center rounded-1"
value={ value && value !== '' && parseInt(value) }
placeholder={ placeholder }
onChange={ e => handleChange(e.target.value) }
size={ 3 }
min={ min }
max={ max } />
)
}
+6
View File
@@ -0,0 +1,6 @@
export const SERVER_URL = 'http://localhost:8080';
export const ROLES = {
ROLE_SUPER_ADMIN : "ROLE_SUPER_ADMIN",
ROLE_BRANCH_ADMIN : "ROLE_BRANCH_ADMIN"
}
@@ -0,0 +1,15 @@
import { createContext, useContext } from 'react';
const SortContext = createContext({ onSort: () => {} });
export function useSortContext() {
return useContext(SortContext);
}
export function SortContextProvider({ onSort, children }) {
return (
<SortContext.Provider value={{ onSort }}>
{ children }
</SortContext.Provider>
)
}
@@ -0,0 +1,3 @@
import { createContext } from 'react';
export const UserContext = createContext();
+35
View File
@@ -0,0 +1,35 @@
/*
Koristiti ovu funkciju za fetchanje u buducnosti kad god je to moguce.
*/
export async function fetchData(url, method, body) {
const headers = new Headers();
const token = localStorage.getItem('token');
if (token) {
headers.append('Authorization', `Bearer ${ token }`);
}
headers.append('Content-Type', 'application/json');
const res = await fetch(url, {
method: method || 'GET',
headers: headers,
body: body ? JSON.stringify(body) : null
});
if (!res) {
return { success: false };
}
const data = res.ok && res.body ? await res.json() : null;
if (res.ok) {
//na svaki ispravan rezultat treba da dobijemo novi token da refreshamo stari
const newToken = res.headers.get('Auth-Token');
if (newToken) {
localStorage.setItem('token', newToken);
}
}
return { data: data, success: res.ok };
}
+14
View File
@@ -0,0 +1,14 @@
import { useEffect } from 'react';
/**
* A hook for using the native JS interval API. The interval is released after the user component dismounts.
* @param callback function to be executed
* @param period interval between executions of the callback function. The first execution happens instantly.
* @param deps {array} optional array of dependencies
*/
export function useInterval(callback, period, deps) {
useEffect(() => {
const interval = setInterval(() => callback(), period)
return () => clearInterval(interval);
}, [ period, ...deps ]);
}
@@ -0,0 +1,70 @@
import { useSearchParams } from 'react-router-dom';
export function useSortSearchState(column) {
const [ search, setSearch ] = useSearchParams();
function serializeToUrl(column, direction) {
return `${column},${direction}`;
}
function deserializeFromUrl(sortQueryParam) {
if (sortQueryParam) {
const sortParts = sortQueryParam.split(',')
return {
column: sortParts[0],
direction: sortParts[1]
}
}
}
function searchContainsColumn(column) {
const sortParam = getCurrentSort();
if (sortParam) {
return deserializeFromUrl(sortParam).column === column;
}
return false;
}
function changeSortDirection() {
const sortParam = getCurrentSort();
if (sortParam) {
const { column, direction } = deserializeFromUrl(sortParam);
if (direction === 'asc') {
setSortDirection(column, 'desc');
} else if (direction === 'desc') {
removeSort()
} else {
setSortDirection(column, 'asc');
}
}
}
function setSortDirection(column, direction = 'asc') {
search.set('sort', serializeToUrl(column, direction));
setSearch(prev => search);
}
function getCurrentSort() {
return search.get('sort');
}
function removeSort() {
search.delete('sort');
setSearch(prev => search)
}
function updateSort(column) {
if (searchContainsColumn(column)) {
changeSortDirection();
} else {
setSortDirection(column, 'asc');
}
return getCurrentSort();
}
return { sort: getCurrentSort(), updateSort: () => updateSort(column) };
}
+22
View File
@@ -0,0 +1,22 @@
* {
margin: 0;
padding: 0;
font-family: 'General Sans', sans-serif;
box-sizing: border-box;
}
html, body, #root {
height: 100%;
}
body {
padding: 0 20px;
background-color: ghostwhite;
}
:root {
/* ovdje definisite konstante boje i sl. koje cete koristiti na vise mjesta */
--blue: #334257;
--light-blue: #548CA8;
--dark-blue: #476072;
}
+17
View File
@@ -0,0 +1,17 @@
import { GoogleOAuthProvider } from "@react-oauth/google";
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
//import ManageServices from "./pages/ManageServices/ManageServices";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<GoogleOAuthProvider clientId="776973117081-smp0drfulvkjk8s55ifr2i7k3uklpr04.apps.googleusercontent.com">
<BrowserRouter>
<App />
</BrowserRouter>
</GoogleOAuthProvider>
</React.StrictMode>
);
@@ -0,0 +1,232 @@
import React, { useState, useEffect } from 'react';
import { Button, Table, Modal, Form } from 'react-bootstrap';
import 'bootstrap/dist/css/bootstrap.min.css';
import { SERVER_URL } from '../../constants.js';
import { UserContext } from '../../context/UserContext.jsx';
import { useNavigate, useParams } from "react-router-dom";
const styles = {
primaryButton: {
backgroundColor: "var(--light-blue)",
borderColor: "var(--light-blue)",
},
infoButton: {
backgroundColor: "var(--light-blue)",
color: 'white',
borderColor: "var(--light-blue)",
},
modalHeader: {
backgroundColor: "var(--blue)",
color: 'white',
},
};
const AdminManageScreen = () => {
const { tenantCode } = useParams();
const [showModal, setShowModal] = useState(false);
const [admins, setAdmins] = useState([]);
const [adminEmail, setAdminEmail] = useState('');
const [adminPassword, setAdminPassword] = useState('');
const [selectedAdminIndex, setSelectedAdminIndex] = useState(null);
const [token, setToken] = useState('');
const [emailError, setEmailError] = useState('');
const [passwordError, setPasswordError] = useState('');
useEffect(() => {
const storedToken = localStorage.getItem('token');
if (storedToken) {
setToken(storedToken);
}
}, []);
useEffect(() => {
if (token) {
fetchAdmins();
}
}, [token]);
const fetchAdmins = async () => {
try {
const requestBody = JSON.stringify({
roleName: 'ROLE_BRANCH_ADMIN'
});
const response = await fetch(`${ SERVER_URL }/api/v1/admin/${tenantCode}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': token
},
body: requestBody
});
if (response.ok) {
const data = await response.json();
setAdmins(data);
} else {
console.error('Unsuccessful API call');
}
} catch (error) {
console.error('Error while making API call:', error);
}
};
const validateEmail = (email) => {
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return re.test(String(email).toLowerCase());
};
const handleAddAdmin = async () => {
const requestBody = {
email: adminEmail,
password: adminPassword,
roleName: 'ROLE_BRANCH_ADMIN'
};
try {
const response = await fetch(`${ SERVER_URL }/api/v1/admin/${tenantCode}/user`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': token
},
body: JSON.stringify(requestBody)
});
if (response.ok) {
const data = await response.json();
setAdmins([...admins, data]);
setShowModal(false);
setAdminEmail('');
setAdminPassword('');
} else {
console.error('Unsuccessful API call');
}
} catch (error) {
console.error('Error while making API call:', error);
}
};
const handleEditAdmin = async () => {
if (!validateEmail(adminEmail)) {
setEmailError('Invalid email address');
return;
}
setEmailError('');
try {
const updatedAdmin = {
email: adminEmail
};
const response = await fetch(`${ SERVER_URL }/api/v1/admin/${tenantCode}/user/${admins[selectedAdminIndex].id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': token
},
body: JSON.stringify(updatedAdmin),
});
if (response.ok) {
const updatedAdmins = [...admins];
updatedAdmins[selectedAdminIndex].email = adminEmail;
setAdmins(updatedAdmins);
setShowModal(false);
setAdminEmail('');
} else {
console.error('Unsuccessful API call');
}
} catch (error) {
console.error('Error while making API call:', error);
}
};
const handleDeleteAdmin = async (userId) => {
try {
const response = await fetch(`${ SERVER_URL }/api/v1/admin/${tenantCode}/user/${userId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'Authorization': token
}
});
if (response.ok) {
const updatedAdmins = admins.filter(admin => admin.id !== userId);
setAdmins(updatedAdmins);
} else {
console.error('Unsuccessful API call');
}
} catch (error) {
console.error('Error while making API call:', error);
}
};
const handleEditClick = (index) => {
const admin = admins[index];
setSelectedAdminIndex(index);
setAdminEmail(admin.email);
setShowModal(true);
};
return (
<div className="text-center mt-5">
<h2>Manage Administrators</h2>
<Button variant="primary" style={styles.primaryButton} className="mb-3" onClick={() => { setShowModal(true); setSelectedAdminIndex(null); }}>Add Admin</Button>
<Table striped bordered hover>
<thead>
<tr>
<th>ID</th>
<th>Email</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{admins.map((admin, index) => (
<tr key={index}>
<td>{admin.id}</td>
<td>{admin.email}</td>
<td>
<Button variant="info" style={styles.infoButton} onClick={() => handleEditClick(index)}>Edit</Button>{' '}
<Button variant="danger" onClick={() => handleDeleteAdmin(admin.id)}>Delete</Button>
</td>
</tr>
))}
</tbody>
</Table>
<Modal show={showModal} onHide={() => { setShowModal(false); setSelectedAdminIndex(null); }}>
<Modal.Header closeButton style={styles.modalHeader}>
<Modal.Title>{selectedAdminIndex !== null ? 'EDIT ADMIN' : 'ADD ADMIN'}</Modal.Title>
</Modal.Header>
<Modal.Body>
<Form>
<Form.Group controlId="formAdminEmail" className="mb-3">
<Form.Label>Email</Form.Label>
<Form.Control type="email" placeholder="Enter admin email" value={adminEmail} onChange={(e) => setAdminEmail(e.target.value)} />
{emailError && <div style={{ color: 'red' }}>{emailError}</div>}
</Form.Group>
{selectedAdminIndex === null && (
<Form.Group controlId="formAdminPassword" className="mb-3">
<Form.Label>Password</Form.Label>
<Form.Control type="password" placeholder="Enter admin password" value={adminPassword} onChange={(e) => setAdminPassword(e.target.value)} />
{passwordError && <div style={{ color: 'red' }}>{passwordError}</div>}
</Form.Group>
)}
</Form>
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={() => { setShowModal(false); setSelectedAdminIndex(null); }}>Close</Button>
{selectedAdminIndex !== null ?
<Button variant="primary" style={styles.primaryButton} onClick={handleEditAdmin}>Save Changes</Button> :
<Button variant="primary" style={styles.primaryButton} onClick={handleAddAdmin}>Add Admin</Button>
}
</Modal.Footer>
</Modal>
</div>
);
};
export default AdminManageScreen;
@@ -0,0 +1,45 @@
#account-settings{
margin: 50px auto;
padding: 20px;
border: 1px solid #ccc;
border-radius: 5px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
#check-box{
margin: 20px auto;
}
#check-box, label{
margin-bottom: 20px;
padding-right: 10px;
}
input[type="submit"] {
background-color: #548CA8;
color: white;
border: none;
padding: 10px 20px;
font-size: 16px;
border-radius: 5px;
cursor: pointer;
transition: background-color 0.3s ease;
}
input[type="submit"]:hover {
background-color: #334257;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
}
#QR-code input[type="submit"][disabled] {
background-color: #ccc;
cursor: not-allowed;
}
#QR-code img {
max-width: 100%;
height: auto;
margin-top: 10px;
display: block;
margin-left: 0;
}
@@ -0,0 +1,78 @@
import { useState, useEffect } from 'react';
import { fetchData } from '../../fetching/Fetch.js';
import { SERVER_URL } from '../../constants';
import "./AdminProfile.css"
export default function AdminProfile(){
const [isChecked, setIsChecked] = useState(false);
const [isQRCodeEnabled, setIsQRCodeEnabled] = useState(false);
const [qrCodeSrc, setQrCodeSrc] = useState('');
const [userData, setUserData] = useState('');
useEffect(() => {
const storedUserData = localStorage.getItem('userData');
setUserData(JSON.parse(storedUserData));
const storedIsTfa = localStorage.getItem('isTfa');
let isTfa = JSON.parse(storedIsTfa);
setIsChecked(isTfa);
setIsQRCodeEnabled(isTfa);
}, []);
const handleSaveChanges = async () =>{
const url = `${ SERVER_URL }/api/v1/auth/tfa`;
const { data, success } = await fetchData(url, 'PUT', {
isTfa: isChecked
});
localStorage.setItem('isTfa', isChecked);
setIsQRCodeEnabled(isChecked);
if(!isChecked){
setQrCodeSrc('');
}
if(success){
let message = 'Success: Your changes have been successfully submitted.';
if(isChecked){
message = message + '\nPlease scan QR code.';
}
alert(message);
}else{
alert('An error occurred. Please try again.');
}
}
const handleCheckBoxChange = () =>{
setIsChecked(!isChecked);
}
const handleGenerateQRCode = () =>{
if(isQRCodeEnabled){
const url = `${ SERVER_URL }/api/v1/auth/tfa?email=${userData.email}`;
fetchData(url, 'GET')
.then(({ data, success }) => {
if (success) {
setQrCodeSrc(data.qrCode);
}
});
}
}
return (
<div id="account-settings">
<h1>Account settings</h1>
<div id="check-box">
<form>
<label htmlFor="2fa">Use two-factor authentication:</label>
<input type="checkbox" id="2fa" name="2fa" checked={isChecked} onChange={handleCheckBoxChange}></input>
</form>
</div>
<div id="QR-code">
<input type="submit" value="Generate QR code" disabled={!isQRCodeEnabled} onClick={handleGenerateQRCode}/>
<img src={qrCodeSrc}></img>
</div>
<div>
<input type="submit" value="Save changes" onClick={handleSaveChanges}/>
</div>
</div>
);
}
@@ -0,0 +1,148 @@
.company-info-update-wrapper {
max-width: 1200px;
margin: 0 auto;
}
.heading2 {
background-color: var(--blue);
color: white;
border-radius: 5px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 2px;
padding: 15px 0;
}
.form-container-comp {
width: 100%;
border: 1px solid #ccc;
border-radius: 5px;
background-color: #ffff;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
display: flex;
}
.form-group-comp {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 10px 0;
}
.labels-comp {
font-weight: bold;
font-size: 20px;
color: #666362;
font-family: "Poppins", sans-serif;
margin-bottom: 10px;
}
.inputs-comp[type="text"],
.inputs-comp[type="file"],
textarea,
select {
width: 80%;
border: 1px solid lightgray;
border-radius: 5px;
font-size: 16px;
box-sizing: border-box;
margin-bottom: 20px;
text-indent: 5px;
padding: 5px 0;
}
.inputs-comp[type="file"] {
width: 80%;
border: 1px solid lightgray;
border-radius: 5px;
font-size: 16px;
box-sizing: border-box;
margin-bottom: 20px;
margin-top: 0.03%;
}
img {
display: block;
max-width: 100%;
margin: 10px auto 0;
}
.left-column {
width: 50%;
}
.left-column .labels-comp {
text-align: center;
}
.right-column {
width: 50%;
}
.right-column .form-group-comp {
margin-left: 10px;
}
.right-column .labels-comp {
text-align: center;
}
/*
?????
*/
.right-column .inputs-comp,
.right-column textarea,
.right-column select {
width: calc(100% - 20px);
}
button[type="submit"]:hover {
background-color: #476072;
/
}
.logo {
width: 250px;
height: 250px;
}
.welcome-message {
width: 40%;
height: 100px;
margin: auto;
overflow: auto;
max-height: 200px;
border: solid 1px black;
}
.welcome-message > p {
padding: 4px;
word-wrap: break-word;
}
.form-select {
border: 1px solid lightgray;
border-radius: 5px;
font-size: 16px;
box-sizing: border-box;
}
.company-info-submit-btn {
display: flex;
margin: 10px auto;
background-color: var(--blue);
padding: 10px 40px;
color: white;
border-radius: 8px;
border: none;
font-size: 20px;
font-weight: bold;
cursor: pointer;
width: 12%;
}
@@ -0,0 +1,153 @@
import React, { useContext, useEffect, useState } from 'react';
import './CompanyInfoUpdate.css';
import { useNavigate, useParams } from 'react-router-dom';
import { SERVER_URL } from '../../constants.js';
import { UserContext } from '../../context/UserContext.jsx';
import { fetchData } from '../../fetching/Fetch.js';
export default function CompanyInfoUpdate() {
const navigate = useNavigate();
const { tenantCode } = useParams();
const [name, setName] = useState('');
const [hqAddress, setHQAddress] = useState('');
const [welcomeMessage, setWelcomeMessage] = useState('');
const [font, setFont] = useState('Arial');
const [file, setFile] = useState('');
const { user, setUser } = useContext(UserContext);
useEffect(() => {
// Na pocetku popunimo polja sa vec postojecim podacima
const url = `${ SERVER_URL }/api/v1/tenants/${ tenantCode }`;
fetchData(url, 'GET')
.then(({ data, success }) => {
if (success) {
setName(data.name);
setHQAddress(data.hqAddress);
setWelcomeMessage(data.welcomeMessage);
setFont(data.font);
setFile(data.logo.base64Logo);
}
});
}, []);
function handleChange(e) {
const file = e.target.files[0];
const reader = new FileReader();
reader.onloadend = () => {
setFile(reader.result);
};
reader.readAsDataURL(file);
}
async function submitForm() {
try {
const url = `${ SERVER_URL }/api/v1/tenants/${ tenantCode }`;
const { data, success } = await fetchData(url, 'PUT', {
name: name,
hqAddress: hqAddress,
font: font,
welcomeMessage: welcomeMessage,
logo: file
});
if (success) {
alert('Success: Your changes have been successfully submitted.');
} else {
alert(`Error while trying to save your changes. Please try again.`);
}
} catch (error) {
console.error('Error:', error);
alert('Error: An error occurred while submitting your changes.');
}
}
return (
<div className="company-info-update-wrapper">
<div className="heading2">
<h2>COMPANY DETAILS</h2>
</div>
<div className="form-container-comp">
<div className="left-column">
<form>
<div className="form-group-comp">
<label className="labels-comp" htmlFor="name" id="Name">Name</label>
<input className="inputs-comp"
type="text"
id="name"
value={ name }
onChange={ (e) => setName(e.target.value) }
required
/>
</div>
<div className="form-group-comp">
<label className="labels-comp" htmlFor="logo" id="Logo">Logo</label>
<input className="inputs-comp"
type="file"
onChange={ handleChange }
accept="image/*"
required
/>
{ file && <img className="logo" src={ file } alt="Uploaded Logo" /> }
</div>
</form>
</div>
<div className="right-column">
<form>
<div className="form-group-comp">
<label className="labels-comp" htmlFor="hqAddress">HQ Address</label>
<input className="inputs-comp"
type="text"
id="hqAddress"
value={ hqAddress }
onChange={ (e) => setHQAddress(e.target.value) }
required
/>
</div>
<div className="form-group-comp">
<label className="labels-comp" htmlFor="welcomeMessage">Welcome Message</label>
<input className="inputs-comp"
type="text"
id="welcomeMessage"
value={ welcomeMessage }
onChange={ (e) => setWelcomeMessage(e.target.value) }
required
/>
</div>
<div className="welcome-message">
<p style={ { fontFamily: font } }>{ welcomeMessage }</p>
</div>
<div className="fontSelect">
<label className="labels-comp" htmlFor="font">Font</label>
<select
className="form-select"
id="font"
value={ font }
onChange={ (e) => setFont(e.target.value) }
required
>
<option value="Arial">Arial</option>
<option value="Times New Roman">Times New Roman</option>
<option value="Verdana">Verdana</option>
<option value="Helvetica">Helvetica</option>
<option value="Montserrat">Montserrat</option>
<option value="Calibri">Calibri</option>
<option value="Futura">Futura</option>
<option value="Bodoni">Bodoni</option>
<option value="Rockwell">Rockwell</option>
<option value="Comic Sans MS">Comic Sans MS</option>
</select>
</div>
</form>
</div>
</div>
<button type="submit" onClick={ submitForm } className="company-info-submit-btn">Submit</button>
</div>
);
}
@@ -0,0 +1,4 @@
.h1-hp{
text-align: center;
margin-top: 1%;
}
@@ -0,0 +1,11 @@
import 'bootstrap/dist/css/bootstrap.min.css';
import './HomePage.css'
export default function HomePage() {
return (
<main className="background-hp">
<h1 className="h1-hp">Dashboard</h1>
</main>
)
}
@@ -0,0 +1,169 @@
#login-form {
position: relative;
width: 420px;
max-width: 100%;
margin: 150px auto 50px;
background-color: #334257;
border-radius: 10px;
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1);
font-family: "Poppins", sans-serif;
}
#login-form h1 {
text-align: center;
margin: 0;
padding: 20px 0;
font-size: 28px;
font-weight: bold;
color: white;
}
#login-form form {
padding: 20px;
background-color: white;
border-radius: 10px;
font-family: "Poppins", sans-serif;
}
#login-form form label {
display: block;
margin-bottom: 8px;
font-size: 14px;
color: gray;
font-family: "Poppins", sans-serif;
}
#login-form form input[type="text"],
#login-form form input[type="password"] {
width: 100%;
padding: 12px;
border: 1px solid lightgray;
border-radius: 5px;
font-size: 16px;
box-sizing: border-box;
margin-bottom: 20px;
}
#login-form form input[type="submit"] {
width: 100%;
margin-top: 10px;
padding: 12px;
background-color: #548CA8;
border: none;
color: white;
font-size: 16px;
font-weight: bold;
border-radius: 5px;
cursor: pointer;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: background-color 0.3s ease;
margin-bottom: 5px;
}
#login-form form input[type="submit"]:hover {
background-color: #334257;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
}
#registration {
text-align: center;
}
.form-group {
margin-bottom: 20px;
position: relative;
}
.error {
color: red;
font-size: 12px;
position: absolute;
bottom: -20px;
left: 0;
width: 100%;
}
#login-form {
position: relative;
width: 420px;
max-width: 100%;
margin: 150px auto 50px;
background-color: #334257;
border-radius: 10px;
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1);
font-family: "Poppins", sans-serif;
}
#login-form h1 {
text-align: center;
margin: 0;
padding: 20px 0;
font-size: 28px;
font-weight: bold;
color: white;
}
#login-form form {
padding: 20px;
background-color: white;
border-radius: 10px;
font-family: "Poppins", sans-serif;
}
#login-form form label {
display: block;
margin-bottom: 8px;
font-size: 14px;
color: gray;
font-family: "Poppins", sans-serif;
}
#login-form form input[type="text"],
#login-form form input[type="password"] {
width: 100%;
padding: 12px;
border: 1px solid lightgray;
border-radius: 5px;
font-size: 16px;
box-sizing: border-box;
margin-bottom: 20px;
}
#login-form form input[type="submit"] {
width: 100%;
margin-top: 10px;
padding: 12px;
background-color: #548CA8;
border: none;
color: white;
font-size: 16px;
font-weight: bold;
border-radius: 5px;
cursor: pointer;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: background-color 0.3s ease;
margin-bottom: 5px;
}
#login-form form input[type="submit"]:hover {
background-color: #334257;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
}
#registration {
text-align: center;
}
.form-group {
margin-bottom: 20px;
position: relative;
}
.error {
color: red;
font-size: 12px;
position: absolute;
bottom: -20px;
left: 0;
width: 100%;
}
@@ -0,0 +1,119 @@
import React, { useState } from 'react';
import validator from 'validator';
import './LoginScreen.css';
import { useNavigate } from 'react-router-dom';
import { SERVER_URL } from '../../constants.js';
import { fetchData } from '../../fetching/Fetch.js';
export default function LoginScreen() {
const navigate = useNavigate();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const handleSubmit = async (event) => {
event.preventDefault();
if (!username.trim()) {
setError('Username is required');
return;
}
if (!validator.isEmail(username) && !validator.isMobilePhone(username, 'any')) {
setError('Invalid username format. Please enter a valid email or phone number.');
return;
}
if (!password.trim()) {
setError('Password is required.');
return;
}
try {
const url = `${SERVER_URL}/api/v1/auth/login`;
const { data, success } = await fetchData(url, 'POST', {
email: username,
password: password
});
if (success) {
if (data.userData == undefined) {
localStorage.setItem('userData', JSON.stringify(data));
} else {
localStorage.setItem('userData', JSON.stringify(data.userData));
localStorage.setItem('token', data.token);
setUser(data.userData);
}
setIsSubmitted(true);
if (data.token) {
localStorage.setItem('isTfa', false);
navigate(`/${data.userData.tenantCode}/home`);
} else {
localStorage.setItem('isTfa', true);
navigate('/loginauth');
}
} else {
setError('Your credentials are incorrect.');
}
} catch (error) {
console.error('Error:', error);
setError('An error occurred. Please try again.');
}
};
const handleUsernameChange = (event) => {
setUsername(event.target.value);
setError('');
};
const handlePasswordChange = (event) => {
setPassword(event.target.value);
setError('');
};
/*
if (isSubmitted) {
//navigate('/loginAuth');
navigate('/companydetails');
}*/
return (
<div id="login-form">
<h1>LOGIN</h1>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="username">Email or phone number:</label>
<input
type="text"
id="username"
name="username"
value={username}
onChange={handleUsernameChange}
/>
{error && (error.includes('Username') || error.includes('Invalid')) &&
<p className="error">{error}</p>}
{error && (error.includes('credentials')) && <p className="error">{error}</p>}
</div>
<div className="form-group">
<label htmlFor="password">Password:</label>
<input
type="password"
id="password"
name="password"
value={password}
onChange={handlePasswordChange}
/>
{error && error.includes('Password') && <p className="error">{error}</p>}
</div>
<input type="submit" value="Submit" />
<div style={{
display: 'flex',
justifyContent: 'center'
}}>
</div>
</form>
</div>
);
};
@@ -0,0 +1,302 @@
import React, { useState, useEffect } from 'react';
import { Button, Table, Modal, Form, ListGroup } from 'react-bootstrap';
import 'bootstrap/dist/css/bootstrap.min.css';
import { fetchData } from '../../fetching/Fetch.js';
import { useParams } from 'react-router-dom';
import { SERVER_URL } from '../../constants.js';
const styles = {
primaryButton: {
backgroundColor: '#548CA8',
borderColor: '#548CA8',
},
infoButton: {
backgroundColor: '#548CA8',
color: 'white',
borderColor: '#548CA8',
},
modalHeader: {
backgroundColor: '#334257',
color: 'white',
},
};
const ManageBranchesScreen = () => {
const [showModal, setShowModal] = useState(false);
const [manageBranches, setManageBranches] = useState([]);
const [branchName, setBranchName] = useState('');
const [selectedBranchIndex, setSelectedBranchIndex] = useState(null);
const [tellerStations, setTellerStations] = useState([]);
const [newStationName, setNewStationName] = useState('');
const [deleteConfirmation, setDeleteConfirmation] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const { tenantCode } = useParams();
const url = `${SERVER_URL}/api/v1/branches/${tenantCode}`;
useEffect(() => {
const fetchBranches = async () => {
try {
const response = await fetchData(url, 'GET');
if (!response.success) {
throw new Error('Network response was not ok');
}
setManageBranches(response.data);
} catch (error) {
console.error('Error:', error);
setErrorMessage('Failed to fetch branches.');
}
};
fetchBranches();
}, [SERVER_URL, url]);
useEffect(() => {
if (!showModal) {
setBranchName('');
setTellerStations([]);
setSelectedBranchIndex(null);
setErrorMessage('');
}
}, [showModal]);
const handleEditClick = (index) => {
const branch = manageBranches[index];
setSelectedBranchIndex(index);
setBranchName(branch.name);
setTellerStations(branch.tellerStations ? [...branch.tellerStations] : []);
setShowModal(true);
};
const handleAddBranch = async () => {
if (branchName.trim() === '' || tellerStations.length === 0) {
setErrorMessage('Please fill out all fields.');
return;
}
const newBranch = {
name: branchName,
tellerStations: tellerStations.map(station => station.name)
};
try {
const response = await fetchData(url, 'POST', newBranch);
if (!response.success) {
throw new Error('Network response was not ok');
}
setManageBranches([...manageBranches, response.data]);
setShowModal(false);
} catch (error) {
console.error('Error:', error);
setErrorMessage('Failed to create branch.');
}
};
const handleAddTellerStation = () => {
if (newStationName.trim() === '') {
return;
}
const newStation = { id: tellerStations.length + 1, name: newStationName };
setTellerStations([...tellerStations, newStation]);
setNewStationName('');
};
const handleEditTellerStation = async () => {
if (newStationName.trim() === '' || selectedBranchIndex === null) {
return;
}
const branchId = manageBranches[selectedBranchIndex].id;
const newStation = { name: newStationName };
try {
const response = await fetchData(`${url}/${branchId}/stations`, 'POST', newStation);
if (!response.success) {
throw new Error('Network response was not ok');
}
const updatedStations = [...tellerStations, response.data];
setTellerStations(updatedStations);
setNewStationName('');
} catch (error) {
console.error('Error:', error);
setErrorMessage('Failed to add teller station.');
}
};
const handleRemoveTellerStation = (index) => {
const updatedStations = [...tellerStations];
updatedStations.splice(index, 1);
setTellerStations(updatedStations);
};
const handleEditRemoveTellerStation = async (index) => {
const stationToRemove = tellerStations[index];
const branchId = manageBranches[selectedBranchIndex].id;
const stationId = stationToRemove.id;
try {
const response = await fetchData(`${url}/${branchId}/stations/${stationId}`, 'DELETE');
if (!response.success) {
throw new Error('Network response was not ok');
}
const updatedStations = [...tellerStations];
updatedStations.splice(index, 1);
setTellerStations(updatedStations);
} catch (error) {
console.error('Error:', error);
setErrorMessage('Failed to remove teller station.');
}
};
const handleDeleteBranch = (index) => {
setDeleteConfirmation(true);
setSelectedBranchIndex(index);
};
const confirmDeleteBranch = async () => {
const branchId = manageBranches[selectedBranchIndex].id;
const urlToDelete = `${url}/${branchId}`;
try {
const response = await fetchData(urlToDelete, 'DELETE');
if (!response.success) {
throw new Error('Network response was not ok');
}
const updatedBranches = [...manageBranches];
updatedBranches.splice(selectedBranchIndex, 1);
setManageBranches(updatedBranches);
setDeleteConfirmation(false);
} catch (error) {
console.error('Error:', error);
setErrorMessage('Failed to delete branch.');
}
};
const handleEditBranch = async () => {
if (branchName.trim() === '' || tellerStations.length === 0 || selectedBranchIndex === null) {
setErrorMessage('Please fill out all fields.');
return;
}
const branchId = manageBranches[selectedBranchIndex].id;
const updatedBranch = {
name: branchName
};
try {
const response = await fetchData(`${url}/${branchId}`, 'PUT', updatedBranch);
if (!response.success) {
throw new Error('Network response was not ok');
}
const updatedBranches = [...manageBranches];
updatedBranches[selectedBranchIndex] = response.data;
setManageBranches(updatedBranches);
setShowModal(false);
} catch (error) {
console.error('Error:', error);
setErrorMessage('Failed to update branch.');
}
};
return (
<div className="text-center">
<h2>Manage Branches</h2>
<Button variant="primary" style={styles.primaryButton} className="mb-3" onClick={() => { setShowModal(true); setSelectedBranchIndex(null); }}>Add Branch</Button>
<Table striped bordered hover>
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Teller Stations</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{manageBranches.map((branch, index) => (
<tr key={index}>
<td>{branch.id}</td>
<td>{branch.name}</td>
<td>{branch.tellerStations ? branch.tellerStations.map(station => station.name).join(', ') : '-'}</td>
<td>
<Button variant="info" style={styles.infoButton} onClick={() => handleEditClick(index)}>Edit</Button>{' '}
<Button variant="danger" onClick={() => handleDeleteBranch(index)}>Delete</Button>
</td>
</tr>
))}
</tbody>
</Table>
<Modal show={showModal} onHide={() => { setShowModal(false); setSelectedBranchIndex(null); }}>
<Modal.Header closeButton style={styles.modalHeader}>
<Modal.Title>{selectedBranchIndex !== null ? 'EDIT BRANCH' : 'ADD BRANCH'}</Modal.Title>
</Modal.Header>
<Modal.Body>
<Form>
<Form.Group controlId="formBranchName" className="mb-3">
<Form.Label>Name</Form.Label>
<Form.Control type="text" placeholder="Enter branch name" value={branchName} onChange={(e) => setBranchName(e.target.value)} />
</Form.Group>
<Form.Group controlId="formNewStation" className="mb-3">
<Form.Label>New Teller Station</Form.Label>
<div className="d-flex align-items-center">
<Form.Control type="text" placeholder="Enter new station name" value={newStationName} onChange={(e) => setNewStationName(e.target.value)} />
<Button variant="primary" onClick={selectedBranchIndex !== null ? handleEditTellerStation : handleAddTellerStation} style={{ backgroundColor: '#548CA8', color: 'white', borderColor: '#548CA8', marginLeft: '10px' }}>Add</Button>
</div>
</Form.Group>
<Form.Group controlId="formTellerStations" className="mb-3">
<Form.Label>Teller Stations</Form.Label>
<ListGroup>
{tellerStations.map((station, index) => (
<ListGroup.Item key={index}>
{station.name}{' '}
{selectedBranchIndex !== null ?
<Button variant="danger" size="sm" onClick={() => handleEditRemoveTellerStation(index)}>X</Button> :
<Button variant="danger" size="sm" onClick={() => handleRemoveTellerStation(index)}>X</Button>
}
</ListGroup.Item>
))}
</ListGroup>
</Form.Group>
</Form>
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={() => { setShowModal(false); setSelectedBranchIndex(null); }}>Close</Button>
{selectedBranchIndex !== null ?
<Button variant="primary" style={styles.primaryButton} onClick={handleEditBranch}>Save Changes</Button> :
<Button variant="primary" style={styles.primaryButton} onClick={handleAddBranch}>Add Branch</Button>
}
</Modal.Footer>
</Modal>
<Modal show={deleteConfirmation} onHide={() => setDeleteConfirmation(false)}>
<Modal.Header closeButton style={{ backgroundColor: '#334257', color: 'white' }}>
<Modal.Title>CONFIRMATION</Modal.Title>
</Modal.Header>
<Modal.Body>
<p>Are you sure you want to delete this branch?</p>
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={() => setDeleteConfirmation(false)}>Cancel</Button>
<Button variant="danger" onClick={confirmDeleteBranch}>Delete</Button>
</Modal.Footer>
</Modal>
<Modal show={errorMessage !== ''} onHide={() => setErrorMessage('')} backdrop="static" keyboard={false}>
<Modal.Header closeButton style={{ backgroundColor: '#dc3545', color: 'white' }}>
<Modal.Title>ERROR</Modal.Title>
</Modal.Header>
<Modal.Body>
<p>{errorMessage}</p>
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={() => setErrorMessage('')}>Close</Button>
</Modal.Footer>
</Modal>
</div>
);
};
export default ManageBranchesScreen;
@@ -0,0 +1,39 @@
#root-displays {
width: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
#table-custom-displays {
width: 100%;
max-width: 1000px;
text-align: center;
}
.button-custom-displays {
margin: 0 5px;
}
.button-custom-blue-displays {
background-color: var(--light-blue);
}
.button-custom-blue-displays:hover {
background-color: #476072;
}
.button-custom-blue-displays:active {
background-color: var(--blue) !important;
}
#button-add-displays {
width: 100%;
max-width: 175px;
}
.modal-custom-header-displays {
background-color: var(--blue);
color: white;
}
@@ -0,0 +1,282 @@
import React, { useState, useEffect } from "react";
import { useParams } from "react-router-dom";
import "bootstrap/dist/css/bootstrap.min.css";
import Table from "react-bootstrap/Table";
import Button from "react-bootstrap/Button";
import Modal from "react-bootstrap/Modal";
import Form from "react-bootstrap/Form";
import "./ManageDisplays.css";
import { fetchData } from "../../fetching/Fetch";
import { SERVER_URL } from '../../constants.js';
const ManageDisplays = () => {
const { tenantCode } = useParams();
const [displays, setDisplays] = useState([]);
const [showAdd, setShowAdd] = useState(false);
const [showEdit, setShowEdit] = useState(false);
const [showDelete, setShowDelete] = useState(false);
const [selectedDisplayId, setSelectedDisplayId] = useState(-1);
const [displayNameInput, setDisplayNameInput] = useState("");
const [selectedBranchId, setSelectedBranchId] = useState(-1);
const [branches, setBranches] = useState([]);
useEffect(() => {
fetchData(`${SERVER_URL}/api/v1/branches/${tenantCode}`, "GET")
.then((res) => {
if (res.success) {
setBranches(res.data);
}
})
.catch((error) => {
console.error("Error fetching branches:", error);
});
}, [tenantCode]);
useEffect(() => {
if (branches.length !== 0) {
setSelectedBranchId(branches[0].id);
}
}, [branches]);
const getDisplays = () => {
fetchData(
`${SERVER_URL}/api/v1/displays/${tenantCode}`,
"GET"
).then((res) => {
if (res.success) {
setDisplays(res.data);
}
});
};
const addDisplay = (displayName, selectedBranchId) => {
fetchData(
`${SERVER_URL}/api/v1/displays/${tenantCode}`,
"POST",
{ name: displayName, branchId: selectedBranchId}
).then((res) => {
if (res.success) {
getDisplays();
}
});
};
const editDisplay = (displayName) => {
fetchData(
`${SERVER_URL}/api/v1/displays/${tenantCode}/${selectedDisplayId}`,
"PUT",
{ name: displayName}
).then((res) => {
if (res.success) {
getDisplays();
}
});
};
const deleteDisplay = () => {
fetchData(
`${SERVER_URL}/api/v1/displays/${tenantCode}/${selectedDisplayId}`,
"DELETE"
).then((res) => {
if (res.success) {
getDisplays();
}
});
};
useEffect(() => {
getDisplays();
}, []);
return (
<>
<div id="root-displays">
<h2>Manage Displays</h2>
<Table id="table-custom-displays" variant="light" striped bordered hover>
<thead>
<tr>
<th>Id</th>
<th>Name</th>
<th>Branch</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{displays.map((display) => (
<tr key={display.id}>
<td>{display.id}</td>
<td>{display.name}</td>
<td>{display.branch.name}</td>
<td>
<Button
id="button-edit"
className="button-custom-displays button-custom-blue-displays"
variant="primary"
onClick={() => {
setDisplayNameInput(display.name);
setShowEdit(true);
setSelectedDisplayId(display.id);
}}
>
Edit
</Button>
<Button
id="button-delete"
className="button-custom"
variant="danger"
onClick={() => {
setShowDelete(true);
setSelectedDisplayId(display.id);
}}
>
Delete
</Button>
</td>
</tr>
))}
</tbody>
</Table>
<Button
id="button-add-displays"
className="button-custom-displays button-custom-blue-displays"
variant="success"
onClick={() => {
setShowAdd(true);
}}
>
Add Display
</Button>
<Modal show={showAdd}>
<Modal.Header className="modal-custom-header-displays">
<Modal.Title>Add Display</Modal.Title>
</Modal.Header>
<Modal.Body>
<Form>
<Form.Group className="mb-3" controlId="formBasicEmail">
<Form.Label>Display Name</Form.Label>
<Form.Control
value={displayNameInput}
onChange={(e) => setDisplayNameInput(e.target.value)}
type="text"
placeholder="Enter Display Name"
/>
</Form.Group>
<Form.Group className="mb-3" controlId="formBasicBranch">
<Form.Label>Branch</Form.Label>
<Form.Select value = {selectedBranchId} onChange = {(b) => {
setSelectedBranchId(b.target.value);
}}>
{branches.map(branch => (
<option key={branch.id} value={branch.id}>{branch.name}</option>
))}
</Form.Select>
</Form.Group>
</Form>
</Modal.Body>
<Modal.Footer>
<Button
variant="secondary"
onClick={() => {
setDisplayNameInput("");
setShowAdd(false);
}}
>
Close
</Button>
<Button
className="button-custom-blue-displays"
variant="primary"
onClick={() => {
if (displayNameInput === "") {
alert("Display name cannot be empty!");
return;
}
setShowAdd(false);
addDisplay(displayNameInput, selectedBranchId);
setDisplayNameInput("");
}}
>
Add Display
</Button>
</Modal.Footer>
</Modal>
<Modal show={showEdit}>
<Modal.Header className="modal-custom-header">
<Modal.Title>Edit Display</Modal.Title>
</Modal.Header>
<Modal.Body>
<Form>
<Form.Group className="mb-3" controlId="formBasicEmail">
<Form.Label>Display Name</Form.Label>
<Form.Control
type="text"
placeholder="Enter Display Name"
value={displayNameInput}
onChange={(e) => setDisplayNameInput(e.target.value)}
/>
</Form.Group>
</Form>
</Modal.Body>
<Modal.Footer>
<Button
variant="secondary"
onClick={() => {
setDisplayNameInput("");
setShowEdit(false);
}}
>
Close
</Button>
<Button
className="button-custom-blue-displays"
variant="primary"
onClick={() => {
if (displayNameInput === "") {
alert("Display name cannot be empty!");
return;
}
setShowEdit(false);
editDisplay(displayNameInput, selectedBranchId);
setDisplayNameInput("");
}}
>
Edit Display
</Button>
</Modal.Footer>
</Modal>
<Modal show={showDelete}>
<Modal.Header className="modal-custom-header">
<Modal.Title>Delete Display</Modal.Title>
</Modal.Header>
<Modal.Body>
<p>Are you sure you want to delete this display?</p>
</Modal.Body>
<Modal.Footer>
<Button
variant="secondary"
onClick={() => {
setDisplayNameInput("");
setShowDelete(false);
}}
>
Close
</Button>
<Button
variant="danger"
onClick={() => {
setShowDelete(false);
deleteDisplay();
}}
>
Delete Display
</Button>
</Modal.Footer>
</Modal>
</div>
</>
);
};
export default ManageDisplays;
@@ -0,0 +1,345 @@
import React, { useState, useEffect } from 'react';
import { Button, Table, Modal, Form, Dropdown, DropdownButton } from 'react-bootstrap';
import 'bootstrap/dist/css/bootstrap.min.css';
import { fetchData } from '../../fetching/Fetch.js';
import { useParams } from 'react-router-dom';
import { SERVER_URL } from '../../constants.js';
export default function ManageGroupsScreen() {
const [showModal, setShowModal] = useState(false);
const [deleteConfirmation, setDeleteConfirmation] = useState(false);
const [groups, setGroups] = useState([]);
const [groupName, setGroupName] = useState('');
const [selectedBranches, setSelectedBranches] = useState([]);
const [selectedServices, setSelectedServices] = useState([]);
const [availableBranches, setAvailableBranches] = useState([]);
const [availableServices, setAvailableServices] = useState([]);
const [selectedGroup, setSelectedGroup] = useState();
const [errorMessage, setErrorMessage] = useState('');
const { tenantCode } = useParams();
const url = `${ SERVER_URL }/api/v1/`;
useEffect(() => {
fetchGroups();
}, []);
useEffect(() => {
if (!showModal) {
// Reset modal state when it closes
setGroupName('')
setSelectedGroup(undefined)
setSelectedBranches([])
setSelectedServices([])
setAvailableBranches([])
setAvailableServices([])
setErrorMessage('')
}
}, [showModal]);
const isValid = groupName.trim() !== '' && selectedBranches.length !== 0 && selectedServices.length !== 0
function fetchAvailableBranches(group) {
fetchData(`${ url }groups/${ tenantCode }/${ group.id }/assignable/branch`)
.then(response => response.data)
.then(setAvailableBranches)
.catch(console.error)
}
function fetchAvailableServices(group) {
fetchData(`${ url }groups/${ tenantCode }/${ group.id }/assignable/service`)
.then(response => response.data)
.then(setAvailableServices)
.catch(console.error)
}
function fetchGroups() {
fetchData(`${ url }groups/${ tenantCode }`, 'GET')
.then(response => response.data)
.then(setGroups)
}
function handleAddGroup() {
if (!isValid) {
setErrorMessage('Please fill out all fields.');
return;
}
const groupData = {
name: groupName,
branchIds: selectedBranches.map(branch => branch.id),
serviceIds: selectedServices.map(service => service.id)
};
fetchData(`${ url }groups/${ tenantCode }`, 'POST', groupData)
.then(fetchGroups)
.catch(console.error)
setShowModal(false)
}
function handleEditGroup() {
if (!isValid) {
setErrorMessage('Please fill out all fields.');
return
}
const updatedGroup = {
...selectedGroup,
name: groupName,
branches: selectedBranches,
services: selectedServices
};
fetchData(`${ url }groups/${ tenantCode }/${ selectedGroup?.id }`, 'PUT', updatedGroup)
.then(fetchGroups)
.catch(console.error)
setShowModal(false)
}
function handleDeleteGroup(group) {
setSelectedGroup(group)
setDeleteConfirmation(true);
}
function confirmDeleteGroup() {
fetchData(`${ url }groups/${ tenantCode }/${ selectedGroup?.id }`, 'DELETE')
.then(fetchGroups)
.catch(console.error)
setDeleteConfirmation(false)
setSelectedGroup(undefined)
}
function handleEditClick(group) {
setSelectedGroup(group)
setGroupName(group.name)
setSelectedBranches(group.branches)
setSelectedServices(group.services)
fetchAvailableBranches(group)
fetchAvailableServices(group)
setShowModal(true)
}
function handleBranchSelection(branchId) {
const branchToAdd = availableBranches.find(branch => branch.id == branchId)
setSelectedBranches([ ...selectedBranches, branchToAdd ])
if (selectedGroup) {
fetchData(`${ url }groups/${ tenantCode }/${ selectedGroup.id }/branches/${ branchId }`, 'PUT')
.then(() => fetchAvailableBranches(selectedGroup))
.then(fetchGroups)
.catch(console.error)
} else {
const updatedAvailable = availableBranches.filter(branch => branch.id != branchId)
setAvailableBranches(updatedAvailable)
}
}
function handleRemoveBranch(branch) {
const updatedBranches = selectedBranches.filter(selectedBranch => selectedBranch.id !== branch.id);
setSelectedBranches(updatedBranches);
if (selectedGroup) {
fetchData(`${ url }groups/${ tenantCode }/${ selectedGroup?.id }/branches/${ branch.id }`, 'DELETE')
.then(() => fetchAvailableBranches(selectedGroup))
.then(fetchGroups)
.catch(console.error)
} else {
setAvailableBranches([ ...availableBranches, branch ])
}
}
function handleServiceSelection(serviceId) {
const serviceToAdd = availableServices.find(service => service.id == serviceId)
setSelectedServices([ ...selectedServices, serviceToAdd ])
if (selectedGroup) {
fetchData(`${ url }groups/${ tenantCode }/${ selectedGroup.id }/services/${ serviceId }`, 'PUT')
.then(() => fetchAvailableServices(selectedGroup))
.then(fetchGroups)
.catch(console.error)
} else {
const updatedAvailable = availableServices.filter(service => service.id != serviceId)
setAvailableServices(updatedAvailable)
}
}
function handleRemoveService(service) {
const updatedServices = selectedServices.filter(selectedService => selectedService.id !== service.id);
setSelectedServices(updatedServices);
if (selectedGroup) {
fetchData(`${ url }groups/${ tenantCode }/${ selectedGroup?.id }/services/${ service.id }`, 'DELETE')
.then(() => fetchAvailableServices(selectedGroup))
.then(fetchGroups)
.catch(console.error)
} else {
setAvailableServices([ ...availableServices, service ])
}
}
function onStartAddGroup() {
setSelectedBranches([])
setSelectedServices([])
fetchData(`${ url }branches/${ tenantCode }`, 'GET')
.then(response => response.data)
.then(setAvailableBranches)
.catch(console.error)
fetchData(`${ url }tenants/${ tenantCode }/services`)
.then(response => response.data)
.then(setAvailableServices)
.catch(console.error)
setShowModal(true)
}
return (
<div className="text-center">
<h2>Groups of Branches</h2>
<Button variant="primary" style={ { backgroundColor: '#548CA8', borderColor: '#548CA8' } } className="mb-3"
onClick={ onStartAddGroup }>Add Group</Button>
<Table striped bordered hover>
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Branches</th>
<th>Services</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{ groups.map((group, index) => (
<tr key={ index }>
<td>{ group.id }</td>
<td>{ group.name }</td>
<td>{ group.branches.map(branch => branch.name).join(', ') }</td>
<td>{ group.services.map(service => service.name).join(', ') }</td>
<td className="d-flex justify-content-center gap-2">
<Button variant="info"
style={ { backgroundColor: '#548CA8', color: 'white', borderColor: '#548CA8' } }
onClick={ () => handleEditClick(group) }>
Edit
</Button>
<Button variant="danger"
onClick={ () => handleDeleteGroup(group) }>
Delete
</Button>
</td>
</tr>
)) }
</tbody>
</Table>
<Modal show={ showModal } onHide={ () => {
setShowModal(false);
} }>
<Modal.Header closeButton style={ { backgroundColor: '#334257', color: 'white' } }>
<Modal.Title>{ selectedGroup ? 'EDIT GROUP' : 'ADD GROUP' }</Modal.Title>
</Modal.Header>
<Modal.Body>
<Form>
<Form.Group controlId="formGroupName" className="mb-3">
<Form.Label>Name</Form.Label>
<Form.Control type="text"
placeholder="Enter group name"
value={ groupName }
onChange={ (e) => setGroupName(e.target.value) } />
</Form.Group>
<Form.Group controlId="formGroupBranches" className="mb-3">
{ selectedBranches.map((branch, index) => (
<span key={ index } className="badge bg-secondary m-1"
style={ { display: 'inline-flex', alignItems: 'center' } }>
{ branch.name }{ ' ' }
<Button variant="danger" size="sm" style={ {
marginLeft: '5px',
padding: '2px 5px',
backgroundColor: '#dc3545',
borderColor: '#dc3545'
} } onClick={ () => handleRemoveBranch(branch) }>X</Button>
</span>
)) }
<DropdownButton
title="Select Branches"
onSelect={ (eventKey) => handleBranchSelection(eventKey) }
variant="btn btn-outline-secondary"
>
{ availableBranches.map((branch, index) => (
<Dropdown.Item key={ index } eventKey={ branch.id }>{ branch.name }</Dropdown.Item>
)) }
</DropdownButton>
</Form.Group>
<Form.Group controlId="formGroupServices" className="mb-3">
{ selectedServices.map((service, index) => (
<span key={ index } className="badge bg-secondary m-1"
style={ { display: 'inline-flex', alignItems: 'center' } }>
{ service.name }{ ' ' }
<Button variant="danger" size="sm" style={ {
marginLeft: '5px',
padding: '2px 5px',
backgroundColor: '#dc3545',
borderColor: '#dc3545'
} } onClick={ () => handleRemoveService(service) }>X</Button>
</span>
)) }
<DropdownButton
title="Select Services"
onSelect={ (eventKey) => handleServiceSelection(eventKey) }
variant="btn btn-outline-secondary"
>
{ availableServices.map((service, index) => (
<Dropdown.Item key={ index }
eventKey={ service.id }>{ service.name }</Dropdown.Item>
)) }
</DropdownButton>
</Form.Group>
</Form>
</Modal.Body>
<Modal.Footer>
{ selectedGroup ?
<Button variant="primary" style={ { backgroundColor: '#548CA8', borderColor: '#548CA8' } }
onClick={ handleEditGroup }>Save Changes</Button> :
<Button variant="primary" style={ { backgroundColor: '#548CA8', borderColor: '#548CA8' } }
onClick={ handleAddGroup }>Add Group</Button>
}
</Modal.Footer>
</Modal>
<Modal show={ deleteConfirmation } onHide={ () => setDeleteConfirmation(false) }>
<Modal.Header closeButton style={ { backgroundColor: '#334257', color: 'white' } }>
<Modal.Title>CONFIRMATION</Modal.Title>
</Modal.Header>
<Modal.Body>
<p>Are you sure you want to delete this group?</p>
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={ () => setDeleteConfirmation(false) }>Cancel</Button>
<Button variant="danger" onClick={ confirmDeleteGroup }>Delete</Button>
</Modal.Footer>
</Modal>
<Modal show={ errorMessage !== '' } onHide={ () => setErrorMessage('') } backdrop="static"
keyboard={ false }>
<Modal.Header closeButton style={ { backgroundColor: '#dc3545', color: 'white' } }>
<Modal.Title>ERROR</Modal.Title>
</Modal.Header>
<Modal.Body>
<p>{ errorMessage }</p>
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={ () => setErrorMessage('') }>Close</Button>
</Modal.Footer>
</Modal>
</div>
);
};
@@ -0,0 +1,39 @@
#root-services {
width: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
#table-custom-services {
width: 100%;
max-width: 1000px;
text-align: center;
}
.button-custom-services {
margin: 0 5px;
}
.button-custom-blue-services {
background-color: var(--light-blue);
}
.button-custom-blue-services:hover {
background-color: #476072;
}
.button-custom-blue-services:active {
background-color: var(--blue) !important;
}
#button-add-services {
width: 100%;
max-width: 175px;
}
.modal-custom-header-services {
background-color: var(--blue);
color: white;
}
@@ -0,0 +1,263 @@
import React, { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import "bootstrap/dist/css/bootstrap.min.css";
import Header from "../../components/Header/Header";
import Table from "react-bootstrap/Table";
import Button from "react-bootstrap/Button";
import Modal from "react-bootstrap/Modal";
import Form from "react-bootstrap/Form";
import "./ManageServices.css";
import { SERVER_URL } from '../../constants.js';
import { fetchData } from "../../fetching/Fetch";
const ManageServices = () => {
const { tenantCode } = useParams();
const [services, setServices] = useState([]);
const [showAdd, setShowAdd] = useState(false);
const [showEdit, setShowEdit] = useState(false);
const [showDelete, setShowDelete] = useState(false);
const [selectedServiceId, setSelectedServiceId] = useState(-1);
const [serviceNameInput, setServiceNameInput] = useState("");
const getServices = () => {
fetchData(
`${SERVER_URL}/api/v1/tenants/${tenantCode}/services`,
"GET"
).then((res) => {
if (res.success) {
setServices(res.data);
}
});
};
const addService = (serviceName) => {
fetchData(
`${SERVER_URL}/api/v1/tenants/${tenantCode}/services`,
"POST",
{ name: serviceName }
).then((res) => {
if (res.success) {
getServices();
}
});
};
const editService = (serviceName) => {
fetchData(
`${SERVER_URL}/api/v1/tenants/${tenantCode}/services/${selectedServiceId}`,
"PUT",
{ name: serviceName }
).then((res) => {
if (res.success) {
getServices();
}
});
};
const deleteService = () => {
fetchData(
`${SERVER_URL}/api/v1/tenants/${tenantCode}/services/${selectedServiceId}`,
"DELETE"
).then((res) => {
if (res.success) {
getServices();
}
});
};
useEffect(() => {
getServices();
}, []);
return (
<>
<div id="root-services">
<h2>Manage Services</h2>
<Table id="table-custom-services" variant="light" striped bordered hover>
<thead>
<tr>
<th>Id</th>
<th>Name</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{services.map((service) => (
<tr key={service.id}>
<td>{service.id}</td>
<td>{service.name}</td>
<td>
<Button
id="button-edit"
className="button-custom-services button-custom-blue-services"
variant="primary"
onClick={() => {
setServiceNameInput(service.name);
setShowEdit(true);
setSelectedServiceId(service.id);
}}
>
Edit
</Button>
<Button
id="button-delete"
className="button-custom"
variant="danger"
onClick={() => {
setShowDelete(true);
setSelectedServiceId(service.id);
}}
>
Delete
</Button>
</td>
</tr>
))}
</tbody>
</Table>
<Button
id="button-add-services"
className="button-custom-services button-custom-blue-services"
variant="success"
onClick={() => {
setShowAdd(true);
}}
>
Add Service
</Button>
<Modal show={showAdd}>
<Modal.Header className="modal-custom-header-services">
<Modal.Title>Add Service</Modal.Title>
</Modal.Header>
<Modal.Body>
<Form>
<Form.Group
className="mb-3"
controlId="formBasicEmail"
>
<Form.Label>Service Name</Form.Label>
<Form.Control
value={serviceNameInput}
onChange={(e) =>
setServiceNameInput(e.target.value)
}
type="text"
placeholder="Enter Service Name"
/>
</Form.Group>
</Form>
</Modal.Body>
<Modal.Footer>
<Button
variant="secondary"
onClick={() => {
setServiceNameInput("");
setShowAdd(false);
}}
>
Close
</Button>
<Button
className="button-custom-blue-services"
variant="primary"
onClick={() => {
if(serviceNameInput === "") {
alert("Service name cannot be empty!");
return;
}
setShowAdd(false);
addService(serviceNameInput);
setServiceNameInput("");
}}
>
Add Service
</Button>
</Modal.Footer>
</Modal>
<Modal show={showEdit}>
<Modal.Header className="modal-custom-header">
<Modal.Title>Edit Service</Modal.Title>
</Modal.Header>
<Modal.Body>
<Form>
<Form.Group
className="mb-3"
controlId="formBasicEmail"
>
<Form.Label>Service Name</Form.Label>
<Form.Control
type="text"
placeholder="Enter Service Name"
value={serviceNameInput}
onChange={(e) =>
setServiceNameInput(e.target.value)
}
/>
</Form.Group>
</Form>
</Modal.Body>
<Modal.Footer>
<Button
variant="secondary"
onClick={() => {
setServiceNameInput("");
setShowEdit(false);
}}
>
Close
</Button>
<Button
className="button-custom-blue-services"
variant="primary"
onClick={() => {
if(serviceNameInput === "") {
alert("Service name cannot be empty!");
return;
}
setShowEdit(false);
editService(serviceNameInput);
setServiceNameInput("");
}}
>
Edit Service
</Button>
</Modal.Footer>
</Modal>
<Modal show={showDelete}>
<Modal.Header className="modal-custom-header">
<Modal.Title>Delete Service</Modal.Title>
</Modal.Header>
<Modal.Body>
<p>Are you sure you want to delete this service?</p>
</Modal.Body>
<Modal.Footer>
<Button
variant="secondary"
onClick={() => {
setServiceNameInput("");
setShowDelete(false);
}}
>
Close
</Button>
<Button
variant="danger"
onClick={() => {
setShowDelete(false);
deleteService();
}}
>
Delete Service
</Button>
</Modal.Footer>
</Modal>
</div>
</>
);
};
export default ManageServices;
@@ -0,0 +1,340 @@
import React, { useEffect, useState } from 'react';
import { Button, Dropdown, DropdownButton, Form, Modal, Table } from 'react-bootstrap';
import 'bootstrap/dist/css/bootstrap.min.css';
import { useParams } from 'react-router-dom';
import { SERVER_URL } from '../../constants.js';
import { fetchData } from '../../fetching/Fetch.js';
const ManageStationScreen = () => {
const [showServiceModal, setShowServiceModal] = useState(false);
const [showDisplayModal, setShowDisplayModal] = useState(false);
const [stations, setStations] = useState([]);
const [selectedStation, setSelectedStation] = useState(null);
const [selectedBranch, setSelectedBranch] = useState(null);
const [branches, setBranches] = useState([]);
const [selectedServices, setSelectedServices] = useState([]);
const [availableServices, setAvailableServices] = useState([]);
const [availableDisplays, setAvailableDisplays] = useState([]);
const [errorMessage, setErrorMessage] = useState('');
const { tenantCode } = useParams();
const url = `${ SERVER_URL }/api/v1/`;
useEffect(() => {
fetchBranches();
}, []);
useEffect(() => {
if (selectedBranch) {
fetchStationsForBranch();
fetchAvailableDisplays(selectedBranch);
} else {
setStations([]);
}
}, [selectedBranch]);
useEffect(() => {
setSelectedServices(selectedStation ? selectedStation.services : []);
if (selectedStation) {
fetchAvailableServices(selectedStation);
}
}, [selectedStation]);
function fetchBranches() {
fetchData(`${ url }branches/${ tenantCode }`, 'GET')
.then(response => response.data)
.then(setBranches)
.catch(console.error)
}
function fetchStationsForBranch() {
fetchData(`${ url }stations/${ tenantCode }/${ selectedBranch.id }`, 'GET')
.then(response => response.data)
.then(setStations)
.catch(console.error)
}
function fetchAvailableDisplays(branch) {
fetchData(`${ url }displays/unassigned/${ tenantCode }/${ branch.id }`, 'GET')
.then(response => response.data)
.then(setAvailableDisplays)
.catch(console.error)
}
function fetchAssignableServices(station) {
fetchData(`${ url }stations/${ tenantCode }/${ station.id }/services/assignable`)
.then(response => response.data)
.then(setAvailableServices)
.catch(console.error)
}
function onEditServices(station) {
fetchAssignableServices(station)
setShowServiceModal(true)
setSelectedStation(station)
setSelectedServices(station.services)
}
function fetchAvailableServices(station) {
fetchData(`${ url }stations/${ tenantCode }/${ station.id }/services?assigned=false`, 'GET')
.then(response => response.data)
.then(setAvailableServices)
.catch(console.error)
}
function addService(service) {
fetchData(`${ url }stations/${ tenantCode }/${ selectedStation.id }/services/${ service.id }`, 'PUT')
.then(() => fetchAssignableServices(selectedStation))
.then(fetchStationsForBranch)
.catch(console.error)
setSelectedServices([...selectedServices, service]);
}
function removeSelectedService(service) {
fetchData(`${ url }stations/${ tenantCode }/${ selectedStation.id }/services/${ service.id }`, 'DELETE')
.then(() => fetchAssignableServices(selectedStation))
.then(fetchStationsForBranch)
.catch(console.error)
const updatedServices = selectedServices.filter(selectedService => selectedService.id !== service.id)
setSelectedServices(updatedServices)
}
// Ove dvije funkcije ispod isto treba ispraviti da se ne radi filtriranje na frontendu, ali nemam vise vremena.
const addDisplayToStation = async (display) => {
try {
const response = await fetchData(`${ url }stations/${ tenantCode }/${ selectedStation.id }/displays/${ display.id }`, 'PUT');
if (response.success) {
const updatedStations = stations.map(station => {
if (station.id === selectedStation.id) {
if (station.display) {
setAvailableDisplays(prevDisplays => [...prevDisplays, station.display]);
}
return {
...station,
display: display
};
}
return station;
});
setStations(updatedStations);
setAvailableDisplays(prevDisplays => prevDisplays.filter(d => d.id !== display.id));
setSelectedStation(updatedStations.find(station => station.id === selectedStation.id));
setShowDisplayModal(true);
} else {
console.error('Error adding display to station:', response.error);
}
} catch (error) {
console.error('Error adding display to station:', error);
}
};
const removeDisplayFromStation = async () => {
try {
const response = await fetchData(`${ url }stations/${ tenantCode }/${ selectedStation.id }/displays/${ selectedStation.display.id }`, 'DELETE');
if (response.success) {
const updatedStations = stations.map(station => {
if (station.id === selectedStation.id) {
return {
...station,
display: null
};
}
return station;
});
setStations(updatedStations);
setAvailableDisplays(prevDisplays => [...prevDisplays, selectedStation.display]);
setShowDisplayModal(false);
} else {
console.error('Error removing display from station:', response.error);
}
} catch (error) {
console.error('Error removing display from station:', error);
}
};
const handleCloseModal = () => {
setSelectedServices(selectedStation ? selectedStation.services : []);
setShowServiceModal(false);
setShowDisplayModal(false);
};
return (
<div className="text-center">
<h2>Teller Stations</h2>
<DropdownButton title={ selectedBranch ? selectedBranch.name : 'Select Branch' }
variant="btn btn-outline-secondary">
{ branches.map((branch, index) => (
<Dropdown.Item key={ index }
onClick={ () => setSelectedBranch(branch) }>{ branch.name }</Dropdown.Item>
)) }
</DropdownButton>
<div style={ { marginTop: '20px' } }>
<Table striped bordered hover>
<thead>
<tr>
<th>Station ID</th>
<th>Station Name</th>
<th>Services</th>
<th>Service Action</th>
<th>Displays</th>
<th>Display Action</th>
</tr>
</thead>
<tbody>
{ stations.map((station, index) => (
<tr key={ index }>
<td>{ station.id }</td>
<td>{ station.name }</td>
<td>
{
station.services && station.services.length > 0 ? (
station.services.map((service, serviceIndex) => (
<span key={ serviceIndex } style={ { marginRight: '5px' } }>
{ serviceIndex > 0 && ', ' }
{ service.name }
</span>
))
) : (
<span>No services</span>
) }
</td>
<td>
<Button
variant="primary"
style={ { backgroundColor: '#548CA8', color: 'white', borderColor: '#548CA8' } }
onClick={ () => onEditServices(station) }
>
Edit
</Button>
</td>
<td>
{ station.display ? (
<span>{ station.display.name }</span>
) : (
<span>No display</span>
) }
</td>
<td>
<Button
variant="primary"
style={ { backgroundColor: '#548CA8', color: 'white', borderColor: '#548CA8' } }
onClick={ () => {
setShowDisplayModal(true);
setSelectedStation(station);
} }
>
Edit
</Button>
</td>
</tr>
)) }
</tbody>
</Table>
</div>
<Modal show={ showServiceModal } onHide={ handleCloseModal }>
<Modal.Header closeButton style={ { backgroundColor: '#334257', color: 'white' } }>
<Modal.Title>Manage Services for Station</Modal.Title>
</Modal.Header>
<Modal.Body>
<p><strong>Teller Station:</strong> { selectedStation && selectedStation.name }</p>
<div>
<div style={ { marginBottom: '10px' } }>
<strong>Selected Services:</strong>
{ selectedServices.length > 0 ? (
selectedServices.map((service, index) => (
<span key={ index } className="badge bg-secondary m-1"
style={ { display: 'inline-flex', alignItems: 'center' } }>
{ service.name }
<Button variant="danger" size="sm" style={ {
marginLeft: '5px',
padding: '2px 5px',
backgroundColor: '#dc3545',
borderColor: '#dc3545'
} } onClick={ () => removeSelectedService(service) }>X</Button>
</span>
))
) : (
<span> No service selected</span>
) }
</div>
<Form.Group controlId="formGroupService" className="mb-3">
<DropdownButton title={ 'Select Service' } variant="btn btn-outline-secondary">
{ availableServices.map((service, index) => {
const isAssigned = selectedServices.some(selectedService => selectedService.id === service.id);
if (!isAssigned) {
return (
<Dropdown.Item key={ index }
onClick={ () => addService(service) }>{ service.name }</Dropdown.Item>
);
} else {
return null;
}
}) }
</DropdownButton>
</Form.Group>
{ errorMessage && <p className="text-danger">{ errorMessage }</p> }
</div>
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={ handleCloseModal }>Close</Button>
</Modal.Footer>
</Modal>
<Modal show={ showDisplayModal } onHide={ handleCloseModal }>
<Modal.Header closeButton style={ { backgroundColor: '#334257', color: 'white' } }>
<Modal.Title>Manage Display for Station</Modal.Title>
</Modal.Header>
<Modal.Body>
<p><strong>Teller Station:</strong> { selectedStation && selectedStation.name }</p>
<div>
<div style={ { marginBottom: '10px' } }>
<strong>Selected Display:</strong>
{ selectedStation && selectedStation.display ? (
<span className="badge bg-secondary m-1" style={ {
display: 'inline-flex',
alignItems: 'center'
} }> { selectedStation.display.name }
<Button variant="danger" size="sm" style={ {
marginLeft: '5px',
padding: '2px 5px',
backgroundColor: '#dc3545',
borderColor: '#dc3545'
} } onClick={ removeDisplayFromStation }>X</Button>
</span>
) : (
<span> No display selected</span>
) }
</div>
<Form>
<Form.Group controlId="formGroupDisplay" className="mb-3">
<DropdownButton
title={ 'Select Display' }
variant="btn btn-outline-secondary"
>
{ availableDisplays.map((display, index) => (
<Dropdown.Item key={ index }
onClick={ () => addDisplayToStation(display) }>{ display.name }</Dropdown.Item>
)) }
</DropdownButton>
</Form.Group>
</Form>
{ errorMessage && <p className="text-danger">{ errorMessage }</p> }
</div>
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={ handleCloseModal }>Close</Button>
</Modal.Footer>
</Modal>
</div>
);
};
export default ManageStationScreen;
@@ -0,0 +1,41 @@
import { useNavigate } from 'react-router-dom';
import { UserContext } from '../../context/UserContext.jsx';
import {useContext} from "react";
export default function NotFound() {
const navigate = useNavigate();
const { user, setUser } = useContext(UserContext);
function handleHomeClick() {
if (user) {
navigate(`${user.tenantCode}/home`);
} else {
navigate('/login');
}
}
return (
<div style={ {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
gap: '10px'
} }>
<p>
404 - The page does not exist.
</p>
<button style={ {
width: 'fit-content',
border: '1px solid blue',
borderRadius: '5px',
padding: '5px 30px',
cursor: 'pointer'
} }
onClick={ handleHomeClick }>
Go back home?
</button>
</div>
);
}
@@ -0,0 +1,7 @@
body {
margin: 0;
padding: 0;
background-color:ghostwhite;
background-size: cover;
background-position: center;
}

Some files were not shown because too many files have changed in this diff Show More