app: zone-labels client + admin labeling UX (Phase B) + default map = downtown Sandpoint
- zoneLabels client (cached fetch wrapper) hitting bigbrainparking.mowden.top; adminStore keeps the admin password in secure-store. - Admin screen (verify + save password) reachable from Account → Admin. - MeterDetail shows a label badge and, for admins, 2h/3h/4h/Pay-now/Clear labeling controls that PUT/DELETE to the API. - app.json extra.zoneLabelsApiUrl. - Map default view is now downtown Sandpoint (48.2766,-116.5533 @ z14) instead of the continental-US fallback. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c14081d85f
commit
eeaf09ea0e
8 changed files with 384 additions and 4 deletions
|
|
@ -44,6 +44,7 @@
|
||||||
],
|
],
|
||||||
"extra": {
|
"extra": {
|
||||||
"psEnvironment": "prodv2",
|
"psEnvironment": "prodv2",
|
||||||
|
"zoneLabelsApiUrl": "https://bigbrainparking.mowden.top",
|
||||||
"mapStyleUrl": "https://tiles.openfreemap.org/styles/liberty",
|
"mapStyleUrl": "https://tiles.openfreemap.org/styles/liberty",
|
||||||
"mapStyleUrlDark": "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json",
|
"mapStyleUrlDark": "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json",
|
||||||
"unifiedPushDefaultDistributor": "io.heckel.ntfy",
|
"unifiedPushDefaultDistributor": "io.heckel.ntfy",
|
||||||
|
|
|
||||||
18
app/src/api/adminStore.ts
Normal file
18
app/src/api/adminStore.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import * as SecureStore from 'expo-secure-store';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The zone-labels admin password, kept in the OS keystore (never AsyncStorage).
|
||||||
|
* Its presence is what unlocks the labeling controls; it's sent as a Bearer token
|
||||||
|
* to the bigbrainparking.mowden.top API on writes.
|
||||||
|
*/
|
||||||
|
const ADMIN_KEY = 'ps_admin_token';
|
||||||
|
|
||||||
|
export function getAdminToken(): Promise<string | null> {
|
||||||
|
return SecureStore.getItemAsync(ADMIN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setAdminToken(token: string | null): Promise<void> {
|
||||||
|
return token
|
||||||
|
? SecureStore.setItemAsync(ADMIN_KEY, token)
|
||||||
|
: SecureStore.deleteItemAsync(ADMIN_KEY);
|
||||||
|
}
|
||||||
142
app/src/api/zoneLabels.ts
Normal file
142
app/src/api/zoneLabels.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
import Constants from 'expo-constants';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import { getAdminToken } from './adminStore';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client for the BigBrainParking zone-labels API (bigbrainparking.mowden.top).
|
||||||
|
* Reads are public and cached locally so check-in works offline; writes require
|
||||||
|
* the admin token. This is a plain fetch wrapper — the ParkSmarter HttpClient is
|
||||||
|
* too domain-specific (its own auth headers / rolling tokens) to reuse here.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type LabelKind = 'free_2h' | 'free_3h' | 'free_4h' | 'pay_immediate';
|
||||||
|
|
||||||
|
export interface ZoneLabel {
|
||||||
|
zoneId: string;
|
||||||
|
customerId: string | null;
|
||||||
|
zoneName: string | null;
|
||||||
|
kind: LabelKind;
|
||||||
|
note: string | null;
|
||||||
|
updatedAt: number;
|
||||||
|
updatedBy: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CACHE_KEY = 'ps_zone_labels';
|
||||||
|
const BASE_URL = String(Constants.expoConfig?.extra?.zoneLabelsApiUrl ?? '').replace(/\/+$/, '');
|
||||||
|
|
||||||
|
export class ZoneLabelsError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
readonly status?: number,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ZoneLabelsError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hours of free parking for a kind, or null for pay-immediate. */
|
||||||
|
export function labelHours(kind: LabelKind): number | null {
|
||||||
|
return kind === 'free_2h' ? 2 : kind === 'free_3h' ? 3 : kind === 'free_4h' ? 4 : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Short human label for a kind. */
|
||||||
|
export function labelText(kind: LabelKind): string {
|
||||||
|
switch (kind) {
|
||||||
|
case 'free_2h':
|
||||||
|
return 'Free · 2h limit';
|
||||||
|
case 'free_3h':
|
||||||
|
return 'Free · 3h limit';
|
||||||
|
case 'free_4h':
|
||||||
|
return 'Free · 4h limit';
|
||||||
|
case 'pay_immediate':
|
||||||
|
return 'Pay immediately';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let memCache: Record<string, ZoneLabel> | null = null;
|
||||||
|
|
||||||
|
async function loadCache(): Promise<Record<string, ZoneLabel>> {
|
||||||
|
if (memCache) return memCache;
|
||||||
|
const raw = await AsyncStorage.getItem(CACHE_KEY);
|
||||||
|
memCache = raw ? (JSON.parse(raw) as Record<string, ZoneLabel>) : {};
|
||||||
|
return memCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveCache(map: Record<string, ZoneLabel>): Promise<void> {
|
||||||
|
memCache = map;
|
||||||
|
await AsyncStorage.setItem(CACHE_KEY, JSON.stringify(map));
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureConfigured(): void {
|
||||||
|
if (!BASE_URL) throw new ZoneLabelsError('zoneLabelsApiUrl is not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authHeaders(): Promise<Record<string, string>> {
|
||||||
|
const token = await getAdminToken();
|
||||||
|
if (!token) throw new ZoneLabelsError('not authenticated as admin');
|
||||||
|
return { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pull all labels and refresh the local cache. */
|
||||||
|
export async function refreshLabels(): Promise<void> {
|
||||||
|
ensureConfigured();
|
||||||
|
const res = await fetch(`${BASE_URL}/api/labels`);
|
||||||
|
if (!res.ok) throw new ZoneLabelsError('labels fetch failed', res.status);
|
||||||
|
const body = (await res.json()) as { labels?: ZoneLabel[] };
|
||||||
|
const map: Record<string, ZoneLabel> = {};
|
||||||
|
for (const l of body.labels ?? []) map[String(l.zoneId)] = l;
|
||||||
|
await saveCache(map);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cached label for a zone, or null. Never hits the network. */
|
||||||
|
export async function getCachedLabel(
|
||||||
|
zoneId: number | string | null | undefined,
|
||||||
|
): Promise<ZoneLabel | null> {
|
||||||
|
if (zoneId == null) return null;
|
||||||
|
const map = await loadCache();
|
||||||
|
return map[String(zoneId)] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setLabel(
|
||||||
|
zoneId: number | string,
|
||||||
|
kind: LabelKind,
|
||||||
|
meta: { zoneName?: string | null; customerId?: string | number | null } = {},
|
||||||
|
): Promise<ZoneLabel> {
|
||||||
|
ensureConfigured();
|
||||||
|
const res = await fetch(`${BASE_URL}/api/labels/${encodeURIComponent(String(zoneId))}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: await authHeaders(),
|
||||||
|
body: JSON.stringify({
|
||||||
|
kind,
|
||||||
|
zoneName: meta.zoneName ?? null,
|
||||||
|
customerId: meta.customerId ?? null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new ZoneLabelsError('set label failed', res.status);
|
||||||
|
const label = (await res.json()) as ZoneLabel;
|
||||||
|
const map = await loadCache();
|
||||||
|
map[String(zoneId)] = label;
|
||||||
|
await saveCache(map);
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteLabel(zoneId: number | string): Promise<void> {
|
||||||
|
ensureConfigured();
|
||||||
|
const res = await fetch(`${BASE_URL}/api/labels/${encodeURIComponent(String(zoneId))}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: await authHeaders(),
|
||||||
|
});
|
||||||
|
if (!res.ok && res.status !== 404) throw new ZoneLabelsError('delete failed', res.status);
|
||||||
|
const map = await loadCache();
|
||||||
|
delete map[String(zoneId)];
|
||||||
|
await saveCache(map);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verify a candidate admin token against the server (for the settings "Test" button). */
|
||||||
|
export async function verifyAdmin(token: string): Promise<boolean> {
|
||||||
|
ensureConfigured();
|
||||||
|
const res = await fetch(`${BASE_URL}/api/whoami`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
return res.ok;
|
||||||
|
}
|
||||||
|
|
@ -23,6 +23,7 @@ import { NotificationsScreen } from '@/screens/NotificationsScreen';
|
||||||
import { StartSessionScreen } from '@/screens/StartSessionScreen';
|
import { StartSessionScreen } from '@/screens/StartSessionScreen';
|
||||||
import { SessionDetailScreen } from '@/screens/SessionDetailScreen';
|
import { SessionDetailScreen } from '@/screens/SessionDetailScreen';
|
||||||
import { DiagnosticsScreen } from '@/screens/DiagnosticsScreen';
|
import { DiagnosticsScreen } from '@/screens/DiagnosticsScreen';
|
||||||
|
import { AdminScreen } from '@/screens/AdminScreen';
|
||||||
import { useTheme } from '@/theme/ThemeContext';
|
import { useTheme } from '@/theme/ThemeContext';
|
||||||
import { useSessionStatusSync } from '@/notifications/sessionStatus';
|
import { useSessionStatusSync } from '@/notifications/sessionStatus';
|
||||||
import type { ActiveSession, PastSession, Zone } from 'parksmarter-client';
|
import type { ActiveSession, PastSession, Zone } from 'parksmarter-client';
|
||||||
|
|
@ -38,6 +39,7 @@ export type RootStackParamList = {
|
||||||
PaymentMethods: undefined;
|
PaymentMethods: undefined;
|
||||||
Notifications: undefined;
|
Notifications: undefined;
|
||||||
Diagnostics: undefined;
|
Diagnostics: undefined;
|
||||||
|
Admin: undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TabParamList = {
|
export type TabParamList = {
|
||||||
|
|
@ -148,6 +150,7 @@ export function RootNavigator() {
|
||||||
component={DiagnosticsScreen}
|
component={DiagnosticsScreen}
|
||||||
options={{ title: 'Diagnostics' }}
|
options={{ title: 'Diagnostics' }}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen name="Admin" component={AdminScreen} options={{ title: 'Admin' }} />
|
||||||
</Stack.Navigator>
|
</Stack.Navigator>
|
||||||
) : (
|
) : (
|
||||||
<LoginScreen />
|
<LoginScreen />
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,11 @@ export function AccountScreen() {
|
||||||
<Switch value={mode === 'dark'} onValueChange={toggle} />
|
<Switch value={mode === 'dark'} onValueChange={toggle} />
|
||||||
</View>
|
</View>
|
||||||
<Item icon="bug" label="Diagnostics" onPress={() => navigation.navigate('Diagnostics')} />
|
<Item icon="bug" label="Diagnostics" onPress={() => navigation.navigate('Diagnostics')} />
|
||||||
|
<Item
|
||||||
|
icon="shield-checkmark"
|
||||||
|
label="Admin (zone labeling)"
|
||||||
|
onPress={() => navigation.navigate('Admin')}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
|
|
|
||||||
109
app/src/screens/AdminScreen.tsx
Normal file
109
app/src/screens/AdminScreen.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import { useTheme } from '@/theme/ThemeContext';
|
||||||
|
import { getAdminToken, setAdminToken } from '@/api/adminStore';
|
||||||
|
import { verifyAdmin } from '@/api/zoneLabels';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enter the zone-labels admin password. Once verified + saved it unlocks the
|
||||||
|
* labeling controls on the meter detail screen. Stored in the OS keystore.
|
||||||
|
*/
|
||||||
|
export function AdminScreen() {
|
||||||
|
const { colors } = useTheme();
|
||||||
|
const [value, setValue] = useState('');
|
||||||
|
const [authed, setAuthed] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void getAdminToken().then((t) => setAuthed(!!t));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const saveAndTest = async () => {
|
||||||
|
const token = value.trim();
|
||||||
|
if (!token) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const ok = await verifyAdmin(token);
|
||||||
|
if (!ok) {
|
||||||
|
Alert.alert('Not accepted', 'That password was rejected by the server.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await setAdminToken(token);
|
||||||
|
setAuthed(true);
|
||||||
|
setValue('');
|
||||||
|
Alert.alert('Admin enabled', 'You can now label zones from the meter screen.');
|
||||||
|
} catch (e: any) {
|
||||||
|
Alert.alert('Could not verify', e?.message ?? 'Check your connection and try again.');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const signOut = async () => {
|
||||||
|
await setAdminToken(null);
|
||||||
|
setAuthed(false);
|
||||||
|
Alert.alert('Signed out', 'Admin labeling is now disabled on this device.');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView style={{ backgroundColor: colors.bg }} contentContainerStyle={{ padding: 16 }}>
|
||||||
|
<Text style={[styles.section, { color: colors.subtext }]}>
|
||||||
|
Zone labeling {authed ? '· enabled' : '· disabled'}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={[styles.card, { backgroundColor: colors.card }]}>
|
||||||
|
<Text style={[styles.help, { color: colors.subtext }]}>
|
||||||
|
The admin password lets you tag zones as free (2h/3h/4h) or pay-immediately.
|
||||||
|
It's stored securely on this device and sent only to
|
||||||
|
bigbrainparking.mowden.top.
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[styles.input, { color: colors.text, borderColor: colors.border }]}
|
||||||
|
placeholder={authed ? 'Enter a new password to replace' : 'Admin password'}
|
||||||
|
placeholderTextColor={colors.subtext}
|
||||||
|
value={value}
|
||||||
|
onChangeText={setValue}
|
||||||
|
secureTextEntry
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.button, { backgroundColor: colors.primary, opacity: value.trim() ? 1 : 0.5 }]}
|
||||||
|
onPress={saveAndTest}
|
||||||
|
disabled={!value.trim() || busy}
|
||||||
|
>
|
||||||
|
{busy ? (
|
||||||
|
<ActivityIndicator color="#fff" />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.buttonText}>Verify & save</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{authed ? (
|
||||||
|
<TouchableOpacity style={[styles.signout, { borderColor: colors.danger }]} onPress={signOut}>
|
||||||
|
<Text style={{ color: colors.danger, fontWeight: '700' }}>Sign out of admin</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : null}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
section: { marginBottom: 8, marginLeft: 4, fontSize: 13, fontWeight: '600' },
|
||||||
|
card: { borderRadius: 12, padding: 16, gap: 12 },
|
||||||
|
help: { fontSize: 13, lineHeight: 19 },
|
||||||
|
input: { borderWidth: 1, borderRadius: 10, padding: 12, fontSize: 16 },
|
||||||
|
button: { borderRadius: 10, padding: 14, alignItems: 'center' },
|
||||||
|
buttonText: { color: '#fff', fontWeight: '700', fontSize: 16 },
|
||||||
|
signout: { marginTop: 24, borderWidth: 1.5, borderRadius: 12, padding: 14, alignItems: 'center' },
|
||||||
|
});
|
||||||
|
|
@ -24,9 +24,9 @@ const MAP_STYLE_DARK =
|
||||||
(Constants.expoConfig?.extra?.mapStyleUrlDark as string) ??
|
(Constants.expoConfig?.extra?.mapStyleUrlDark as string) ??
|
||||||
'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json';
|
'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json';
|
||||||
|
|
||||||
// Fallback view when we have no GPS and no cached location (continental US).
|
// Default view when we have no last-session lot: all of downtown Sandpoint, ID.
|
||||||
const DEFAULT_CENTER: Coords = { latitude: 39.5, longitude: -98.35 };
|
const DEFAULT_CENTER: Coords = { latitude: 48.2766, longitude: -116.5533 };
|
||||||
const DEFAULT_ZOOM = 4;
|
const DEFAULT_ZOOM = 14;
|
||||||
|
|
||||||
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Alert, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
import { Alert, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
import type { RouteProp } from '@react-navigation/native';
|
import type { RouteProp } from '@react-navigation/native';
|
||||||
import { useNavigation, useRoute } from '@react-navigation/native';
|
import { useNavigation, useRoute } from '@react-navigation/native';
|
||||||
|
|
@ -7,6 +7,25 @@ import type { RootStackParamList } from '@/navigation/RootNavigator';
|
||||||
import { saveKiosk, toSavedKiosk } from '@/features/favorites/favoritesStore';
|
import { saveKiosk, toSavedKiosk } from '@/features/favorites/favoritesStore';
|
||||||
import { useTheme } from '@/theme/ThemeContext';
|
import { useTheme } from '@/theme/ThemeContext';
|
||||||
import type { SpacePolicy } from 'parksmarter-client';
|
import type { SpacePolicy } from 'parksmarter-client';
|
||||||
|
import { getAdminToken } from '@/api/adminStore';
|
||||||
|
import {
|
||||||
|
deleteLabel,
|
||||||
|
getCachedLabel,
|
||||||
|
labelText,
|
||||||
|
refreshLabels,
|
||||||
|
setLabel,
|
||||||
|
type LabelKind,
|
||||||
|
type ZoneLabel,
|
||||||
|
} from '@/api/zoneLabels';
|
||||||
|
|
||||||
|
/** Admin labeling buttons — kind, or 'clear' to remove. */
|
||||||
|
const LABEL_CHOICES: Array<{ label: string; kind: LabelKind | 'clear' }> = [
|
||||||
|
{ label: '2h', kind: 'free_2h' },
|
||||||
|
{ label: '3h', kind: 'free_3h' },
|
||||||
|
{ label: '4h', kind: 'free_4h' },
|
||||||
|
{ label: 'Pay now', kind: 'pay_immediate' },
|
||||||
|
{ label: 'Clear', kind: 'clear' },
|
||||||
|
];
|
||||||
|
|
||||||
type DetailRoute = RouteProp<RootStackParamList, 'MeterDetail'>;
|
type DetailRoute = RouteProp<RootStackParamList, 'MeterDetail'>;
|
||||||
|
|
||||||
|
|
@ -69,6 +88,44 @@ export function MeterDetailScreen() {
|
||||||
const { params } = useRoute<DetailRoute>();
|
const { params } = useRoute<DetailRoute>();
|
||||||
const z = params.zone;
|
const z = params.zone;
|
||||||
const [saved, setSaved] = useState(false);
|
const [saved, setSaved] = useState(false);
|
||||||
|
const [label, setLabelState] = useState<ZoneLabel | null>(null);
|
||||||
|
const [isAdmin, setIsAdmin] = useState(false);
|
||||||
|
const [savingKind, setSavingKind] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
void getAdminToken().then((t) => alive && setIsAdmin(!!t));
|
||||||
|
void getCachedLabel(z.ZoneId).then((l) => alive && setLabelState(l));
|
||||||
|
// Refresh from the server, then re-read this zone's label.
|
||||||
|
void refreshLabels()
|
||||||
|
.then(() => getCachedLabel(z.ZoneId))
|
||||||
|
.then((l) => alive && setLabelState(l))
|
||||||
|
.catch(() => {});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [z.ZoneId]);
|
||||||
|
|
||||||
|
const applyLabel = async (kind: LabelKind | 'clear') => {
|
||||||
|
if (z.ZoneId == null) return;
|
||||||
|
setSavingKind(kind);
|
||||||
|
try {
|
||||||
|
if (kind === 'clear') {
|
||||||
|
await deleteLabel(z.ZoneId);
|
||||||
|
setLabelState(null);
|
||||||
|
} else {
|
||||||
|
const l = await setLabel(z.ZoneId, kind, {
|
||||||
|
zoneName: z.ZoneName ?? null,
|
||||||
|
customerId: z.CustomerId ?? null,
|
||||||
|
});
|
||||||
|
setLabelState(l);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
Alert.alert('Could not save label', e?.message ?? 'error');
|
||||||
|
} finally {
|
||||||
|
setSavingKind(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const onSave = async () => {
|
const onSave = async () => {
|
||||||
await saveKiosk(toSavedKiosk(z));
|
await saveKiosk(toSavedKiosk(z));
|
||||||
|
|
@ -98,6 +155,17 @@ export function MeterDetailScreen() {
|
||||||
<Text style={[styles.sub, { color: colors.subtext }]}>{z.ZoneLocation}</Text>
|
<Text style={[styles.sub, { color: colors.subtext }]}>{z.ZoneLocation}</Text>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.badge,
|
||||||
|
{ backgroundColor: label ? (label.kind === 'pay_immediate' ? '#8a4b00' : '#1b5e20') : colors.card },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={[styles.badgeText, { color: label ? '#fff' : colors.subtext }]}>
|
||||||
|
{label ? labelText(label.kind) : 'Unlabeled'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
<Field label="Scanner code" value={z.ScannerCode} />
|
<Field label="Scanner code" value={z.ScannerCode} />
|
||||||
<Field label="Serial" value={z.TerminalSerNo} />
|
<Field label="Serial" value={z.TerminalSerNo} />
|
||||||
|
|
@ -136,6 +204,36 @@ export function MeterDetailScreen() {
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{isAdmin ? (
|
||||||
|
<View style={[styles.card, { backgroundColor: colors.card }]}>
|
||||||
|
<Text style={[styles.cardTitle, { color: colors.text }]}>Label this zone (admin)</Text>
|
||||||
|
<View style={styles.chipRow}>
|
||||||
|
{LABEL_CHOICES.map((c) => {
|
||||||
|
const active = label?.kind === c.kind;
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={c.label}
|
||||||
|
style={[
|
||||||
|
styles.chip,
|
||||||
|
{
|
||||||
|
borderColor: colors.border,
|
||||||
|
backgroundColor: active ? colors.primary : 'transparent',
|
||||||
|
opacity: savingKind != null && !active ? 0.5 : 1,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onPress={() => applyLabel(c.kind)}
|
||||||
|
disabled={savingKind != null}
|
||||||
|
>
|
||||||
|
<Text style={{ color: active ? '#fff' : colors.text, fontWeight: '600' }}>
|
||||||
|
{c.label}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.button, { backgroundColor: saved ? '#2e7d32' : colors.card }]}
|
style={[styles.button, { backgroundColor: saved ? '#2e7d32' : colors.card }]}
|
||||||
onPress={onSave}
|
onPress={onSave}
|
||||||
|
|
@ -171,4 +269,8 @@ const styles = StyleSheet.create({
|
||||||
policyRate: { fontSize: 13, fontWeight: '600' },
|
policyRate: { fontSize: 13, fontWeight: '600' },
|
||||||
button: { marginTop: 16, borderRadius: 10, padding: 16, alignItems: 'center' },
|
button: { marginTop: 16, borderRadius: 10, padding: 16, alignItems: 'center' },
|
||||||
buttonText: { fontWeight: '600', fontSize: 16 },
|
buttonText: { fontWeight: '600', fontSize: 16 },
|
||||||
|
badge: { alignSelf: 'flex-start', borderRadius: 999, paddingHorizontal: 12, paddingVertical: 5, marginTop: 4 },
|
||||||
|
badgeText: { fontSize: 13, fontWeight: '700' },
|
||||||
|
chipRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
|
||||||
|
chip: { borderWidth: 1, borderRadius: 999, paddingHorizontal: 14, paddingVertical: 8 },
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue