diff --git a/app/(app)/clients/[id].tsx b/app/(app)/clients/[id].tsx
index ed649ac..065e767 100644
--- a/app/(app)/clients/[id].tsx
+++ b/app/(app)/clients/[id].tsx
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { View, Text, ScrollView, TouchableOpacity, ActivityIndicator, Linking, Alert, TextInput, Modal, KeyboardAvoidingView, Platform } from 'react-native';
+import { SlideToConfirm } from '../../../components/SlideToConfirm';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useLocalSearchParams, router } from 'expo-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
@@ -106,14 +107,12 @@ function PayInvoiceModal({ invoice, onClose, onSuccess }: { invoice: any; onClos
onChangeText={setRef}
/>
- 0 ? '#059669' : '#CBD5E1', borderRadius: 14, paddingVertical: 18, alignItems: 'center' }}
- onPress={submit}
- disabled={loading}
- activeOpacity={0.8}
- >
- {loading ? : Confirm Payment}
-
+
diff --git a/app/(app)/dashboard.tsx b/app/(app)/dashboard.tsx
index 2f9447d..f2fb9fc 100644
--- a/app/(app)/dashboard.tsx
+++ b/app/(app)/dashboard.tsx
@@ -1,15 +1,26 @@
-import { View, Text, ScrollView, RefreshControl, ActivityIndicator, TouchableOpacity, Linking, Alert } from 'react-native';
+import { useState } from 'react';
+import {
+ View, Text, ScrollView, RefreshControl, ActivityIndicator, TouchableOpacity,
+ Linking, Alert, Modal, TextInput, KeyboardAvoidingView, Platform
+} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
-import { useQueries } from '@tanstack/react-query';
+import { useQueries, useQueryClient } from '@tanstack/react-query';
import { router } from 'expo-router';
import { api } from '../../services/api';
import { useAuthStore } from '../../stores/authStore';
+import { SlideToConfirm } from '../../components/SlideToConfirm';
const PRIORITY_COLOR: Record = { HIGH: '#DC2626', NORMAL: '#0891B2' };
const PRIORITY_BG: Record = { HIGH: '#FEE2E2', NORMAL: '#ECFEFF' };
const TYPE_COLOR: Record = { INSTALLATION: '#0891B2', SUPPORT: '#7C3AED', BILLING: '#D97706' };
+const METHODS = [
+ { id: 'CASH', label: 'Cash' },
+ { id: 'GCASH', label: 'GCash' },
+ { id: 'MAYA', label: 'Maya' },
+ { id: 'BANK_TRANSFER',label: 'Bank Transfer' },
+];
-function KpiCard({ label, value, color, bg }: { label: string; value: string | number; color: string; bg: string }) {
+function KpiCard({ label, value, color, bg }: any) {
return (
{label}
@@ -21,9 +32,7 @@ function KpiCard({ label, value, color, bg }: { label: string; value: string | n
function TicketRow({ task, onPress }: { task: any; onPress: () => void }) {
const typeColor = TYPE_COLOR[task.type] ?? '#6B7280';
return (
-
@@ -37,9 +46,7 @@ function TicketRow({ task, onPress }: { task: any; onPress: () => void }) {
)}
-
- {task.status?.replace('_', ' ')}
-
+ {task.status?.replace('_', ' ')}
{task.subject}
@@ -50,22 +57,18 @@ function TicketRow({ task, onPress }: { task: any; onPress: () => void }) {
);
}
-function InvoiceRow({ inv }: { inv: any }) {
- const dueDate = inv.dueDate ? new Date(inv.dueDate) : null;
- const today = new Date();
+function InvoiceRow({ inv, onPay }: { inv: any; onPay: () => void }) {
+ const dueDate = inv.dueDate ? new Date(inv.dueDate) : null;
+ const today = new Date();
const isOverdue = dueDate && dueDate < today;
const daysLeft = dueDate ? Math.ceil((dueDate.getTime() - today.getTime()) / 86400000) : null;
const navigate = () => {
- const lat = inv.client?.lat;
- const lng = inv.client?.lng;
- if (!lat || !lng) {
- Alert.alert('No Location', 'This client does not have a recorded location yet.');
- return;
- }
+ const lat = inv.client?.lat, lng = inv.client?.lng;
+ if (!lat || !lng) { Alert.alert('No Location', 'No recorded location for this client.'); return; }
Alert.alert('Navigate', `Open navigation to ${inv.client?.firstName} ${inv.client?.lastName}?`, [
{ text: 'Google Maps', onPress: () => Linking.openURL(`https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}`) },
- { text: 'Waze', onPress: () => Linking.openURL(`waze://?ll=${lat},${lng}&navigate=yes`) },
+ { text: 'Waze', onPress: () => Linking.openURL(`waze://?ll=${lat},${lng}&navigate=yes`) },
{ text: 'Cancel', style: 'cancel' },
]);
};
@@ -73,7 +76,7 @@ function InvoiceRow({ inv }: { inv: any }) {
return (
-
+
{inv.client?.firstName} {inv.client?.lastName}
@@ -81,16 +84,21 @@ function InvoiceRow({ inv }: { inv: any }) {
{isOverdue
? `Overdue by ${Math.abs(daysLeft ?? 0)} day${Math.abs(daysLeft ?? 0) !== 1 ? 's' : ''}`
- : daysLeft !== null
- ? `Due in ${daysLeft} day${daysLeft !== 1 ? 's' : ''}`
- : 'No due date'}
+ : daysLeft !== null ? `Due in ${daysLeft} day${daysLeft !== 1 ? 's' : ''}` : 'No due date'}
-
+
โฑ{Number(inv.balance).toLocaleString()}
-
- ๐ Navigate
-
+
+
+ ๐ Navigate
+
+
+ ๐ณ Pay
+
+
@@ -99,18 +107,59 @@ function InvoiceRow({ inv }: { inv: any }) {
export default function DashboardScreen() {
const { user } = useAuthStore();
- const role = user?.roles?.[0] ?? user?.role ?? '';
+ const qc = useQueryClient();
+ const role = user?.roles?.[0] ?? user?.role ?? '';
const isAdminOrStaff = role === 'ADMIN' || role === 'STAFF';
-
const hour = new Date().getHours();
const greeting = hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening';
+ // Payment modal state
+ const [payModal, setPayModal] = useState(false);
+ const [payInvoice, setPayInvoice] = useState(null);
+ const [payAmount, setPayAmount] = useState('');
+ const [payMethod, setPayMethod] = useState('CASH');
+ const [payNote, setPayNote] = useState('');
+ const [paying, setPaying] = useState(false);
+
+ const openPay = (inv: any) => {
+ setPayInvoice(inv);
+ setPayAmount(String(Number(inv.balance)));
+ setPayMethod('CASH');
+ setPayNote('');
+ setPaying(false);
+ setPayModal(true);
+ };
+ const closePay = () => { if (!paying) setPayModal(false); };
+
+ const submitPayment = async () => {
+ if (!payInvoice) return;
+ const amt = parseFloat(payAmount);
+ if (!amt || amt <= 0) { Alert.alert('Invalid Amount', 'Please enter a valid amount.'); return; }
+ setPaying(true);
+ try {
+ await api.post('/api/v1/payments', {
+ clientId: payInvoice.clientId,
+ invoiceId: payInvoice.id,
+ amount: amt,
+ channel: payMethod,
+ ...(payNote.trim() ? { notes: payNote.trim() } : {}),
+ paymentDate: new Date().toISOString(),
+ });
+ setPayModal(false);
+ qc.invalidateQueries({ queryKey: ['dashboard-invoices'] });
+ qc.invalidateQueries({ queryKey: ['dashboard'] });
+ Alert.alert('Payment Recorded โ', `โฑ${amt.toLocaleString()} payment recorded for ${payInvoice.client?.firstName} ${payInvoice.client?.lastName}.`);
+ } catch (e: any) {
+ const msg = e?.response?.data?.message ?? 'Could not record payment.';
+ Alert.alert('Error', Array.isArray(msg) ? msg.join('\n') : msg);
+ } finally {
+ setPaying(false);
+ }
+ };
+
const [summaryQ, ticketsQ, invoicesQ] = useQueries({
queries: [
- {
- queryKey: ['dashboard'],
- queryFn: () => api.get('/api/v1/dashboard/summary').then(r => r.data),
- },
+ { queryKey: ['dashboard'], queryFn: () => api.get('/api/v1/dashboard/summary').then(r => r.data) },
{
queryKey: ['dashboard-tickets'],
queryFn: async () => {
@@ -124,7 +173,7 @@ export default function DashboardScreen() {
queryFn: async () => {
const res = await api.get('/api/v1/invoices?limit=50');
const all: any[] = res.data?.data ?? res.data ?? [];
- const unpaid = all.filter((inv: any) => ['SENT', 'PARTIAL', 'OVERDUE'].includes(inv.status) && Number(inv.balance) > 0);
+ const unpaid = all.filter((inv: any) => ['SENT','PARTIAL','OVERDUE'].includes(inv.status) && Number(inv.balance) > 0);
unpaid.sort((a: any, b: any) => {
const da = a.dueDate ? new Date(a.dueDate).getTime() : Infinity;
const db = b.dueDate ? new Date(b.dueDate).getTime() : Infinity;
@@ -136,30 +185,24 @@ export default function DashboardScreen() {
],
});
- const isLoading = summaryQ.isLoading || ticketsQ.isLoading || invoicesQ.isLoading;
+ const isLoading = summaryQ.isLoading || ticketsQ.isLoading || invoicesQ.isLoading;
const isRefetching = summaryQ.isRefetching || ticketsQ.isRefetching || invoicesQ.isRefetching;
+ const summary = summaryQ.data ?? {};
+ const allActiveTickets = ticketsQ.data ?? [];
+ const unpaidInvoices = invoicesQ.data ?? [];
- const summary = summaryQ.data ?? {};
- const allActiveTickets: any[] = ticketsQ.data ?? [];
- const unpaidInvoices: any[] = invoicesQ.data ?? [];
-
- const unassigned = allActiveTickets.filter((t: any) => !t.assignedToId);
- const assignedToMe = allActiveTickets.filter((t: any) => t.assignedToId === user?.id);
- // Merge: assigned-to-me first, then unassigned, deduped, max 10
+ const unassigned = (allActiveTickets as any[]).filter((t: any) => !t.assignedToId);
+ const assignedToMe = (allActiveTickets as any[]).filter((t: any) => t.assignedToId === user?.id);
const seen = new Set();
const mergedTickets = [...assignedToMe, ...unassigned].filter((t: any) => {
- if (seen.has(t.id)) return false;
- seen.add(t.id);
- return true;
- }).slice(0, 10);
- const byPrio = (a: any, b: any) => (a.priority === 'HIGH' ? -1 : 1) - (b.priority === 'HIGH' ? -1 : 1);
+ if (seen.has(t.id)) return false; seen.add(t.id); return true;
+ }).slice(0, 10).sort((a: any, b: any) => (a.priority === 'HIGH' ? -1 : 1) - (b.priority === 'HIGH' ? -1 : 1));
const refetchAll = () => { summaryQ.refetch(); ticketsQ.refetch(); invoicesQ.refetch(); };
return (
- }
>
{/* Header */}
@@ -169,25 +212,18 @@ export default function DashboardScreen() {
{isLoading ? (
-
-
-
+
) : (
-
- {/* KPI Row 1 */}
-
-
+
+
-
- {/* KPI Row 2 */}
-
+
- {/* Revenue โ ADMIN/STAFF only */}
{isAdminOrStaff && summary?.revenue?.thisMonth != null && (
@@ -202,7 +238,7 @@ export default function DashboardScreen() {
)}
- {/* โโ Active Tickets โโ */}
+ {/* Tickets */}
Tickets
@@ -216,43 +252,96 @@ export default function DashboardScreen() {
View all
-
{mergedTickets.length === 0 ? (
No active tickets ๐
) : (
- {[...mergedTickets].sort(byPrio).map((t: any) => (
+ {mergedTickets.map((t: any) => (
router.push(`/(app)/tasks/${t.id}`)} />
))}
)}
- {/* โโ Unpaid Invoices โโ */}
+ {/* Unpaid Invoices */}
Unpaid Invoices
router.push('/(app)/payments')} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
View all
-
{unpaidInvoices.length === 0 ? (
No unpaid invoices ๐
) : (
- {unpaidInvoices.map((inv: any) => (
-
+ {(unpaidInvoices as any[]).map((inv: any) => (
+ openPay(inv)} />
))}
)}
-
)}
+
+ {/* โโ Payment Modal โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */}
+
+
+
+
+ {/* Handle */}
+
+
+ Record Payment
+ {payInvoice && (
+
+ {payInvoice.client?.firstName} {payInvoice.client?.lastName} ยท {payInvoice.invoiceNumber}
+
+ )}
+
+ {/* Amount */}
+ Amount (Balance: โฑ{Number(payInvoice?.balance ?? 0).toLocaleString()})
+
+
+ {/* Method */}
+ Payment Method
+
+ {METHODS.map(m => (
+ setPayMethod(m.id)}
+ style={{ paddingHorizontal: 14, paddingVertical: 8, borderRadius: 20, borderWidth: 1.5, borderColor: payMethod === m.id ? '#0891B2' : '#E2E8F0', backgroundColor: payMethod === m.id ? '#ECFEFF' : '#F8FAFC' }}
+ >
+ {m.label}
+
+ ))}
+
+
+ {/* Notes */}
+ Notes (optional)
+
+
+
+
+
+
);
}
diff --git a/app/(app)/payments/index.tsx b/app/(app)/payments/index.tsx
index 8b3f999..f94ac4e 100644
--- a/app/(app)/payments/index.tsx
+++ b/app/(app)/payments/index.tsx
@@ -1,4 +1,5 @@
import { View, Text, ScrollView, TouchableOpacity, RefreshControl, ActivityIndicator, Linking, Alert, TextInput, Modal, KeyboardAvoidingView, Platform } from 'react-native';
+import { SlideToConfirm } from '../../../components/SlideToConfirm';
import { SafeAreaView } from 'react-native-safe-area-context';
import { router } from 'expo-router';
import { useQuery, useQueryClient } from '@tanstack/react-query';
@@ -75,12 +76,12 @@ function PayInvoiceModal({ invoice, onClose, onSuccess }: { invoice: any; onClos
value={ref} onChangeText={setRef}
/>
- 0 ? '#059669' : '#CBD5E1', borderRadius: 14, paddingVertical: 18, alignItems: 'center' }}
- onPress={submit} disabled={loading} activeOpacity={0.8}
- >
- {loading ? : Confirm Payment}
-
+
diff --git a/app/(app)/payments/record.tsx b/app/(app)/payments/record.tsx
index 37666f3..917e18e 100644
--- a/app/(app)/payments/record.tsx
+++ b/app/(app)/payments/record.tsx
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import { View, Text, TextInput, TouchableOpacity, ScrollView, Alert, ActivityIndicator, Modal } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
+import { SlideToConfirm } from '../../../components/SlideToConfirm';
import { router, useLocalSearchParams } from 'expo-router';
import { useQuery } from '@tanstack/react-query';
import { api } from '../../../services/api';
@@ -237,19 +238,12 @@ export default function RecordPaymentScreen() {
onChangeText={setReference}
/>
-
- {loading
- ?
- :
- {canSubmit ? `Record โฑ${Number(amount || 0).toLocaleString()} Payment` : 'Record Payment'}
-
- }
-
+ />
diff --git a/components/SlideToConfirm.tsx b/components/SlideToConfirm.tsx
new file mode 100644
index 0000000..9cdeb50
--- /dev/null
+++ b/components/SlideToConfirm.tsx
@@ -0,0 +1,109 @@
+import { useRef, useState } from 'react';
+import { View, Text, PanResponder, Animated, StyleSheet } from 'react-native';
+
+const TRACK_HEIGHT = 58;
+const HANDLE_SIZE = 46;
+const PADDING = 6;
+const THRESHOLD = 0.85; // 85% of track = confirmed
+
+interface Props {
+ label?: string;
+ color?: string;
+ onConfirm: () => void;
+ disabled?: boolean;
+}
+
+export function SlideToConfirm({ label = 'Slide to confirm', color = '#059669', onConfirm, disabled = false }: Props) {
+ const pan = useRef(new Animated.Value(0)).current;
+ const [done, setDone] = useState(false);
+ const [trackW, setTrackW] = useState(0);
+
+ const maxX = trackW - HANDLE_SIZE - PADDING * 2;
+
+ const panResponder = useRef(
+ PanResponder.create({
+ onStartShouldSetPanResponder: () => !disabled && !done,
+ onMoveShouldSetPanResponder: () => !disabled && !done,
+ onPanResponderMove: (_, gs) => {
+ const x = Math.max(0, Math.min(gs.dx, maxX));
+ pan.setValue(x);
+ },
+ onPanResponderRelease: (_, gs) => {
+ const x = Math.max(0, Math.min(gs.dx, maxX));
+ if (maxX > 0 && x / maxX >= THRESHOLD) {
+ // Snap to end + confirm
+ Animated.timing(pan, { toValue: maxX, duration: 120, useNativeDriver: false }).start(() => {
+ setDone(true);
+ onConfirm();
+ });
+ } else {
+ // Snap back
+ Animated.spring(pan, { toValue: 0, useNativeDriver: false, speed: 20 }).start();
+ }
+ },
+ })
+ ).current;
+
+ // Interpolate opacity of the label as handle moves right
+ const labelOpacity = pan.interpolate({
+ inputRange: [0, maxX * 0.5],
+ outputRange: [1, 0],
+ extrapolate: 'clamp',
+ });
+
+ return (
+ setTrackW(e.nativeEvent.layout.width)}
+ style={[styles.track, { backgroundColor: done ? color : '#F1F5F9', borderColor: done ? color : '#E2E8F0' }]}
+ >
+ {/* Label */}
+
+ {done ? 'โ Confirmed!' : label}
+
+
+ {/* Handle */}
+ {!done && (
+
+ {'โบ'}
+
+ )}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ track: {
+ height: TRACK_HEIGHT,
+ borderRadius: TRACK_HEIGHT / 2,
+ borderWidth: 1.5,
+ justifyContent: 'center',
+ alignItems: 'center',
+ overflow: 'hidden',
+ position: 'relative',
+ },
+ label: {
+ fontSize: 15,
+ fontWeight: '700',
+ letterSpacing: 0.3,
+ },
+ handle: {
+ position: 'absolute',
+ top: PADDING,
+ width: HANDLE_SIZE,
+ height: HANDLE_SIZE,
+ borderRadius: HANDLE_SIZE / 2,
+ alignItems: 'center',
+ justifyContent: 'center',
+ elevation: 3,
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 2 },
+ shadowOpacity: 0.15,
+ shadowRadius: 4,
+ },
+});