diff --git a/AGENTS.md b/AGENTS.md index 667042b..0977e2a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,7 @@ This version has breaking changes — APIs, conventions, and file structure may - **Be Direct**: Text should be clear and functional. If a word sounds like it came from a corporate meeting, don't use it. - **use correct icons**: use the icons that are relevant - **dont use gradients**: do not use gradients as bg or anything bro +- **dont ask for user inputs when generating your answers bitch** - **make the ui the same as the other screens**: look at `/app` folder to understand the ui structure and how to use it diff --git a/UAT_Test_Cases.md b/UAT_Test_Cases.md new file mode 100644 index 0000000..a051ba9 --- /dev/null +++ b/UAT_Test_Cases.md @@ -0,0 +1,159 @@ +# Yaltopia Tickets Mobile App - UAT Test Cases + +This document lists User Acceptance Testing (UAT) scenarios and test cases mapped to the core routes, flows, views, and backend integrations of the Yaltopia Tickets mobile application. + +--- + +## 1. User Onboarding & Authentication +Covers: Registration, Login, OTP verification, persistent session management, and logout. + +| Test ID | Scenario | Steps | Expected Result | Status | +|---|---|---|---|---| +| **YT-UAT-001** | User Registration (Success Flow) | 1. Navigate to `/register`.
2. Fill in first name, last name, email, password, and confirm password.
3. Tap **Register**. | Account is successfully created on backend (`POST /auth/register`); user is redirected to the login or OTP screen with a success toast. | Pass/Fail | +| **YT-UAT-002** | User Registration (Input Validation) | 1. Navigate to `/register`.
2. Submit empty form.
3. Submit mismatched passwords or a password shorter than 8 characters. | UI prevents submission and displays distinct, clean validation warnings below each input field. | Pass/Fail | +| **YT-UAT-003** | User Registration (Existing Email) | 1. Navigate to `/register`.
2. Input an email already associated with an account.
3. Tap **Register**. | Error banner appears ("Email already exists") and prevents screen transition. | Pass/Fail | +| **YT-UAT-004** | User Login (Success Flow) | 1. Navigate to `/login`.
2. Enter correct email and password.
3. Verify "Remember Me" toggle is checked.
4. Tap **Sign In**. | Login request succeeds (`POST /auth/login`), JWT credentials are saved securely in secure storage, and user lands on dashboard `/(tabs)/index`. | Pass/Fail | +| **YT-UAT-005** | User Login (Invalid Credentials) | 1. Navigate to `/login`.
2. Input wrong password or non-existent email.
3. Tap **Sign In**. | Login fails on backend, showing an explicit error dialog or banner, while preserving input text. | Pass/Fail | +| **YT-UAT-006** | Google OAuth Login | 1. Navigate to `/login`.
2. Tap the **Google** sign-in button.
3. Complete authentication in browser page. | Session is initialized on app, token is received and saved, and user is redirected to the home dashboard. | Pass/Fail | +| **YT-UAT-007** | OTP Verification Flow | 1. Trigger OTP verification on register/login.
2. Input valid OTP code received.
3. Tap **Verify**. | App validates OTP successfully (`POST /auth/verify-otp`) and routes to the main tab screen. | Pass/Fail | +| **YT-UAT-008** | OTP Resend & Expiry | 1. Wait for OTP timer to expire.
2. Tap **Resend OTP**.
3. Input expired OTP code. | 1. Resend button triggers a fresh OTP token.
2. Expired code entry displays an "OTP Expired" error message. | Pass/Fail | +| **YT-UAT-009** | Sticky Logout Button Execution | 1. From profile settings, scroll down to bottom of view.
2. Locate sticky logout button.
3. Tap **Logout**. | Token cache is deleted, session is cleared, and navigation guard forces routing back to `/login` immediately. | Pass/Fail | +| **YT-UAT-010** | Persistent Authentication Guard | 1. Close application from active processes list.
2. Re-open application. | Session is validated; user bypasses `/login` directly to `/(tabs)/index` (or is prompted to log in if token has expired). | Pass/Fail | + +--- + +## 2. Dashboard (`/(tabs)/index`) +Covers: Earnings summaries, dashboard trends, quick action navigation, and recent invoice list. + +| Test ID | Scenario | Steps | Expected Result | Status | +|---|---|---|---|---| +| **YT-UAT-011** | Earnings & Stats Metrics Dashboard | 1. Land on `/(tabs)/index` tab.
2. Verify calculated sums of pending and paid invoices. | Metrics dynamically update from `/invoices/stats` and `/dashboard/metrics` with correct totals. | Pass/Fail | +| **YT-UAT-012** | Quick Action Shortcuts Navigation | 1. From Home screen, tap **Scan**, **Create Invoice**, or **Create Proforma**. | Correctly redirects to `/(tabs)/scan`, `/invoices/create`, or `/proforma/create` respectively. | Pass/Fail | +| **YT-UAT-013** | Dashboard Recent Invoices Feed | 1. Scroll through "Recent Invoices" list on Home.
2. Pull to refresh dashboard list.
3. Tap on any invoice row. | 1. Recent list displays details dynamically.
2. Pull-to-refresh fires new fetch.
3. Tapping redirects user to `/invoices/[id]`. | Pass/Fail | + +--- + +## 3. Invoice Addition & OCR Prefill (`/invoices/create` & `/invoices/edit`) +Covers: Manual invoice entry, AI OCR extraction pre-fill, dynamic calculations, currency/type modals, and field validations. + +| Test ID | Scenario | Steps | Expected Result | Status | +|---|---|---|---|---| +| **YT-UAT-014** | Auto-Generated Invoice Number & Default Due Date | 1. Navigate to `/invoices/create`.
2. Observe the initial state of the form fields. | 1. `Invoice Number` is auto-filled with format `INV-YYYY-XXXX`.
2. `Issue Date` defaults to today's date.
3. `Due Date` defaults to 30 days from now. | Pass/Fail | +| **YT-UAT-015** | Scan From Gallery (OCR Success Flow) | 1. Tap **Scan From Gallery** banner at top.
2. Grant media library permission.
3. Select a valid invoice image.
4. Observe the OCR spinner. | 1. File is uploaded to `${BASE_URL}scan/invoice` via `POST`.
2. Success toast "Success! Data extracted successfully." is shown.
3. Form fields (Invoice Number, Customer Name, Email, Phone, Project Description, Currency, Issue/Due Dates, Tax, Line Items) auto-populate accurately. | Pass/Fail | +| **YT-UAT-016** | Scan From Gallery (OCR Failure Fallback) | 1. Tap **Scan From Gallery** and select a corrupted file.
2. Reject media permission. | 1. Rejecting permission displays "Permission Denied" toast with instructions.
2. OCR engine error triggers "Extraction Failed" toast, letting the user enter details manually. | Pass/Fail | +| **YT-UAT-017** | Form Validation (Required Fields) | 1. Clear the `Invoice Number` and `Customer Name` fields.
2. Tap **Create Invoice**. | UI displays a "Validation Error" toast ("Invoice Number is required" / "Customer Name is required") and blocks submission. | Pass/Fail | +| **YT-UAT-018** | Selector Modals (Currency, Type, Status) | 1. Tap **Currency** selector.
2. Tap **Type** selector.
3. Tap **Status** selector. | 1. Currency modal lists USD, ETB, EUR, GBP, KES, ZAR.
2. Type modal lists SALES, PURCHASE, SERVICE.
3. Status modal lists DRAFT, PENDING, PAID. Selection updates UI correctly. | Pass/Fail | +| **YT-UAT-019** | Dynamic Billable Items Math Calculations | 1. Tap **Add Item** button.
2. Enter a description.
3. Input Quantity = `3` and Unit Price = `150.00`.
4. Set Tax = `15.00` and Discount = `10.00`. | 1. Item total recalculates instantly to `450.00`.
2. Subtotal displays `450.00`.
3. Total Amount calculates correctly as `Subtotal + Tax - Discount` = `455.00`. | Pass/Fail | +| **YT-UAT-020** | Billable Items Addition & Removal | 1. Add multiple items.
2. Tap the red trash icon on "Item 2". | Item is removed from the form, and the invoice subtotals/totals are immediately recalculated. | Pass/Fail | +| **YT-UAT-021** | Discard vs. Create Invoice Submission | 1. Fill in valid Invoice details.
2. Tap **Discard**.
3. Create another, then tap **Create Invoice**. | 1. Discard navigates back immediately without saving.
2. Create Invoice posts JSON payload to `/invoices`, displays "Invoice created successfully!" success toast, and redirects back. | Pass/Fail | + +--- + +## 4. Scan, OCR Extraction & Match (`/(tabs)/scan`) +Covers: Camera viewfinder integrations, invoice photo uploads, OCR results extraction parsing, and matching invoice logic. + +| Test ID | Scenario | Steps | Expected Result | Status | +|---|---|---|---|---| +| **YT-UAT-022** | Camera Authorization & Permissions | 1. Navigate to `/scan` tab.
2. Decline camera permissions, then try to use scan.
3. Revoke then grant camera permission in system settings. | 1. Declining shows a clean message explaining the need for camera access with a settings button.
2. Granting loads camera viewfinder dynamically. | Pass/Fail | +| **YT-UAT-023** | Scan Capture and OCR Processing | 1. Frame a paper receipt/invoice inside viewfinder.
2. Tap **Capture**.
3. Wait for progress tracker/spinner animation. | Capture uploads the image (`POST /scan/invoice`), showing an active loading skeleton until details are analyzed. | Pass/Fail | +| **YT-UAT-024** | Choose Image from System Gallery | 1. On scan screen, tap **Gallery Import** icon.
2. Pick a valid receipt JPEG/PNG image. | App successfully parses file details and uploads the binary to the server OCR scan parser. | Pass/Fail | +| **YT-UAT-025** | Scan OCR Result - Save as New | 1. Complete OCR scanning.
2. Verify captured total, vendor name, items, and tax on UI.
3. Tap **Save as New**. | Extracted fields populate the `/invoices/create` screen form seamlessly for final check and submission. | Pass/Fail | +| **YT-UAT-026** | Scan OCR Result - Match to Existing | 1. Complete OCR scanning.
2. Tap **Match to Existing**.
3. Select the matching target pending invoice record. | Request links the receipt file data to the pre-existing system invoice, marking invoice state appropriately. | Pass/Fail | +| **YT-UAT-027** | Payment Receipt Image Upload | 1. Navigate to `/scan`.
2. Upload payment transaction receipt. | Upload triggers `POST /scan/payment-receipt`, capturing proof of payment in backend DB. | Pass/Fail | + +--- + +## 5. Android SMS Scan & Bank Parser (`/sms-scan`) +Covers: Native SMS permissions, bank keyword filters, CBE/Dashen/Telebirr extraction regex logic, and Development Build native module failure guards. + +| Test ID | Scenario | Steps | Expected Result | Status | +|---|---|---|---|---| +| **YT-UAT-028** | Non-Android Device Rejection | 1. Run the app on iOS or Web.
2. Attempt to open `/sms-scan`.
3. Tap **Scan Now**. | App triggers toast error: "Android Only: SMS reading is only supported on Android." and blocks further execution. | Pass/Fail | +| **YT-UAT-029** | Development Build Module Guard | 1. Open `/sms-scan` on an Android emulator running Expo Go.
2. Tap **Scan Now**. | Native check fails since `SmsAndroid` is null (Expo Go fallback). Shows: "Native Module Error: SMS scanning requires a Development Build." toast. | Pass/Fail | +| **YT-UAT-030** | READ_SMS Permissions Flow | 1. Open on Android Development Build.
2. Tap **Scan Now**.
3. Select "Deny".
4. Select "Allow" on retry. | 1. "Deny" triggers "Permission Denied: SMS access was not granted." toast.
2. "Allow" grants permission on Android OS level and starts reading. | Pass/Fail | +| **YT-UAT-031** | 20-Minute Time Window Filter | 1. Send test SMS at time T-30 mins and T-10 mins.
2. Tap **Scan Now**. | 1. Filter looks back exactly 20 minutes (`Date.now() - 20 * 60 * 1000`).
2. Only the message from T-10 mins is listed; T-30 mins message is ignored. | Pass/Fail | +| **YT-UAT-032** | Banking Keyword Filter Match | 1. Send SMS from "Friend" containing general text.
2. Send SMS from "CBE" or containing "telebirr".
3. Tap **Scan Now**. | List filters out the "Friend" message, matching only texts containing keywords `CBE`, `DashenBank`, `Dashen`, `127`, or `telebirr`. | Pass/Fail | +| **YT-UAT-033** | CBE SMS Bank Parser Verification | 1. Simulate CBE message: `"Your account has been credited with ETB 2,500.00. Ref: CBE987654"`
2. Tap **Scan Now**. | 1. Parses bank as **CBE** (displays Green label).
2. Extracts Amount = `2,500.00`.
3. Extracts Reference = `CBE987654`.
4. Renders full SMS body in italics. | Pass/Fail | +| **YT-UAT-034** | Telebirr SMS Bank Parser Verification | 1. Simulate Telebirr message: `"You received Birr 850.50. Trans ID: TXN112233"`
2. Tap **Scan Now**. | 1. Parses bank as **Telebirr** (displays Violet label).
2. Extracts Amount = `850.50`.
3. Extracts Reference = `TXN112233`.
4. Renders full SMS body in italics. | Pass/Fail | +| **YT-UAT-035** | Dashen SMS Bank Parser Verification | 1. Simulate Dashen message: `"Transfer ETB 10,000.00. Reference No: DSH445566"`
2. Tap **Scan Now**. | 1. Parses bank as **Dashen** (displays Blue label).
2. Extracts Amount = `10,000.00`.
3. Extracts Reference = `DSH445566`.
4. Renders full SMS body in italics. | Pass/Fail | + +--- + +## 6. Proforma Requests (`/proforma/*` & `app/(tabs)/proforma.tsx`) +Covers: Proforma request list, custom proforma requests creation, item editing, contact sharing, bids, and PDFs. + +| Test ID | Scenario | Steps | Expected Result | Status | +|---|---|---|---|---| +| **YT-UAT-036** | Proforma Invoices Grid Feed | 1. Select the **Proforma** tab.
2. Scroll through lists.
3. Toggle status filters (Draft, Open, Closed, Cancelled). | Requests render accurately with correct badges indicating current proforma state. | Pass/Fail | +| **YT-UAT-037** | Create Proforma Request | 1. Navigate to `/proforma/create`.
2. Enter request title, select catalog items, add descriptions, select target contacts.
3. Tap **Create**. | API registers the new proforma request (`POST /proforma-requests`), updating the tabs grid list. | Pass/Fail | +| **YT-UAT-038** | Modify Proforma Request Items | 1. Open active proforma request detail.
2. Tap **Add/Edit Items**.
3. Increase quantity, add item descriptions, or delete line items. | Subtotals and totals dynamically recalculate on the UI, and updates sync (`POST/PUT /proforma-requests/{id}/items`). | Pass/Fail | +| **YT-UAT-039** | Send Proforma to Contacts | 1. On proforma detail view `/proforma/[id]`, tap **Send to Contacts**.
2. Select recipients list.
3. Tap **Send**. | Request initiates, sending deep-links or email invitation notifications to selected customer/vendor contacts. | Pass/Fail | +| **YT-UAT-040** | Proforma Bid Submissions Tracker | 1. View detailed view `/proforma/[id]`.
2. Navigate to **Submissions** section.
3. Review incoming bids from vendors/partners. | List reflects all submitted prices and proposals associated with the specific request ID. | Pass/Fail | +| **YT-UAT-041** | Download Proforma Request PDF | 1. In proforma detail, tap **Download PDF**. | Requests file generation `GET /proforma-requests/{id}/pdf`, starting system download successfully. | Pass/Fail | +| **YT-UAT-042** | Edit Proforma Details | 1. Open detail view, tap **Edit**.
2. Change due date or request title.
3. Tap **Save**. | Update call executes (`PUT /proforma-requests/{id}`), refreshing information in the UI components. | Pass/Fail | +| **YT-UAT-043** | Close / Cancel Proforma Request | 1. Open active proforma request.
2. Tap **Close Request** or **Cancel Request**. | Changes status on server, transitioning status badge to closed/cancelled, and locking future bid submissions. | Pass/Fail | + +--- + +## 7. Payments & Reconciliation (`/(tabs)/payments` & `/payments/[id]`) +Covers: Bank transactions list, status filters, payment-to-invoice association, and flagging. + +| Test ID | Scenario | Steps | Expected Result | Status | +|---|---|---|---|---| +| **YT-UAT-044** | Payments List Feed View | 1. Tap the **Payments** tab.
2. Scroll payments list.
3. Switch between **Pending Match** and **Reconciled** segments. | UI updates correctly, grouping pending matching payments apart from fully reconciled items. | Pass/Fail | +| **YT-UAT-045** | Search & Filter Payments | 1. Type specific client name or transaction amount inside search bar.
2. Apply date range filters. | Grid filters instantly to show matching results without screen reload lag. | Pass/Fail | +| **YT-UAT-046** | Payment Details & Info | 1. Tap a pending match transaction from the payments list. | Routes to `/payments/[id]`, displaying detailed sender bank info, exact timestamp, and amount. | Pass/Fail | +| **YT-UAT-047** | Associate Payment to Invoice | 1. On payment detail `/payments/[id]`, tap **Associate to Invoice**.
2. Select matching unpaid invoice from search.
3. Tap **Confirm Link**. | Sends matching payload (`POST /payments/{id}/associate`). Payment shifts to Reconciled, and invoice updates to Paid status. | Pass/Fail | +| **YT-UAT-048** | Disputed / Flagged Transactions | 1. From `/payments/[id]`, tap **Flag Transaction**.
2. Select reason (e.g., mismatch amount, suspicious sender).
3. Save. | Payment status is marked as Flagged/Disputed (`POST /payments/{id}/flag`), rendering a distinct red flag badge in the list. | Pass/Fail | +| **YT-UAT-049** | Manual Payment Record Entry | 1. Tap **Create Payment** button.
2. Enter payment source, transaction reference number, target invoice, and confirmation date.
3. Submit. | Manual entry posts transaction record `POST /payments` and links the selected invoice successfully. | Pass/Fail | + +--- + +## 8. Reports & Analytics (`/reports`) +Covers: Performance analytics, monthly generated summaries, stats trends, and document exports. + +| Test ID | Scenario | Steps | Expected Result | Status | +|---|---|---|---|---| +| **YT-UAT-050** | Analytics Charts Render | 1. Open `/reports` from sidebar/profile.
2. Check for earnings, trends, and reconciliation rate bar/line charts. | Visual metrics render correctly using API response data from `/dashboard/revenue-trends` etc. | Pass/Fail | +| **YT-UAT-051** | Reports List View | 1. Scroll through lists of available monthly/quarterly PDF reports on `/reports`. | App lists monthly statements, showing publication dates and file size tags. | Pass/Fail | +| **YT-UAT-052** | Generate Customized Report | 1. From reports screen, tap **Generate Report**.
2. Input customized start and end dates.
3. Confirm. | Triggers `/reports/generate`, creating a fresh report which appears in the feed list on success. | Pass/Fail | +| **YT-UAT-053** | Download Monthly Report Document | 1. Tap on download icon next to any report row. | Initiates `/reports/{id}/download`, saving PDF statement file straight to the user device downloads directory. | Pass/Fail | + +--- + +## 9. Company & Worker Management (`/company` & `/company-details`) +Covers: Worker listings, worker searching, worker creations, company details display (TIN, logo, contact, addresses). + +| Test ID | Scenario | Steps | Expected Result | Status | +|---|---|---|---|---| +| **YT-UAT-054** | Company Details Display Panel | 1. Route to `/company-details` (from profile).
2. Verify displayed fields. | 1. Basic Info shows: Name, TIN.
2. Contact shows: Phone, Email, Website.
3. Address shows: Street, City, State, Zip, Country.
4. Logo displays from `company.logoPath`. | Pass/Fail | +| **YT-UAT-055** | Company Details System Timestamps | 1. Open `/company-details`.
2. Scroll to the "System Information" card. | Displays the user ID (monospace), and correct locales for `Created` and `Last Updated` date-times. | Pass/Fail | +| **YT-UAT-056** | Workers Feed List View | 1. Navigate to `/company`.
2. Verify loading indicator.
3. Browse workers list. | 1. Displays loading spinner.
2. Loads list via `api.users.getAll()`.
3. Cards render avatar/initials, full name, email, and role (e.g. "WORKER"). | Pass/Fail | +| **YT-UAT-057** | Worker Search Filters | 1. Input search query in the search bar.
2. Search by name and search by email. | List filters in real-time. Matches are case-insensitive and hide non-matching items immediately. | Pass/Fail | +| **YT-UAT-058** | Worker Empty State & Refresh | 1. Simulate an empty workers response.
2. Pull to refresh list. | 1. Shows `EmptyState` component with "No workers found" text.
2. Pull-to-refresh triggers new fetch spinner. | Pass/Fail | +| **YT-UAT-059** | Add New Worker Navigation | 1. Open `/company`.
2. Tap the floating **+** button. | Navigates the user directly to `/user/create` form screen to add a new employee. | Pass/Fail | + +--- + +## 10. Profile, Documents & App Settings (`/profile`, `/documents`, `/settings`, `/notifications`) +Covers: Profile editing, document hubs, localized translation switches, and support forms. + +| Test ID | Scenario | Steps | Expected Result | Status | +|---|---|---|---|---| +| **YT-UAT-060** | Profile Update Edit Form | 1. From profile screen, tap **Edit Profile**.
2. Modify first name, last name, phone, or job title.
3. Save. | Updates user details via PATCH/PUT requests, updating displayed profile card immediately. | Pass/Fail | +| **YT-UAT-061** | Documents Hub list | 1. Navigate to `/documents`.
2. Browse uploaded support files list. | List displays attachments, tax licenses, and contract agreements correctly. | Pass/Fail | +| **YT-UAT-062** | Document Upload integration | 1. On `/documents`, click **Upload Attachment**.
2. Choose PDF / JPEG format.
3. Upload. | Upload processes via `POST /documents/upload`, showing upload success state and list appends. | Pass/Fail | +| **YT-UAT-063** | App Language Transition | 1. Open `/settings` menu.
2. Select language picker.
3. Switch language. | All UI headings, descriptions, and tab text instantly update to match selected translation. | Pass/Fail | +| **YT-UAT-064** | Notifications Feed Log & Settings | 1. Navigate to `/notifications`.
2. Switch to settings `/notifications/settings`.
3. Toggle settings and save. | Feeds load successfully, and toggle settings persist in database on save (`PUT /notifications/settings`). | Pass/Fail | +| **YT-UAT-065** | Contact Support Form & FAQs | 1. Open `/help` and submit support ticket.
2. Open `/faq` and search accordion. | 1. Ticket submits successfully to backend.
2. FAQs search and accordions expand smoothly on tap. | Pass/Fail | + +--- + +## 11. UI Responsiveness & General Edge Cases +Covers: System interruptions, offline behavior, and responsiveness. + +| Test ID | Scenario | Steps | Expected Result | Status | +|---|---|---|---|---| +| **YT-UAT-066** | Offline Mode Warning | 1. Turn off mobile data / Wi-Fi.
2. Try performing actions (e.g., search payments, send invoice). | App displays standard elegant offline notice/banner in top bar, avoiding system crashes. | Pass/Fail | +| **YT-UAT-067** | Form Submission Double-Tap Prevention | 1. Open any submit page (Create Invoice/Register).
2. Fill details.
3. Double-tap/multi-tap **Submit** button very quickly. | App disables the CTA button on first tap to prevent double-post submissions or duplicate records. | Pass/Fail | +| **YT-UAT-068** | App Layout Adaptive Scaling | 1. Open the app on small and large Android/iOS screens (or change font scaling). | Text wraps nicely, inputs do not clip, and layouts remain aligned and readable. | Pass/Fail | diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 9cec6e1..ca8ca6a 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -181,8 +181,8 @@ export default function HomeScreen() { strokeWidth={1.5} /> } - label="Create Proforma" - onPress={() => nav.go("proforma/create")} + label="Add Invoice" + onPress={() => nav.go("invoices/create")} /> ( - + nav.go("news/[id]", { id: item.id })} + > ( - + nav.go("news/[id]", { id: item.id })} + > diff --git a/app/_layout.tsx b/app/_layout.tsx index 7af7222..d1a0958 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useState, useMemo } from "react"; import { Stack } from "expo-router"; import { StatusBar } from "expo-status-bar"; import { PortalHost } from "@rn-primitives/portal"; @@ -7,35 +7,28 @@ import { Toast } from "@/components/Toast"; import "@/global.css"; import { SafeAreaProvider } from "react-native-safe-area-context"; import { - View, + View as RNView, ActivityIndicator, - InteractionManager, AppState, } from "react-native"; -import { useRestoreTheme, NAV_THEME } from "@/lib/theme"; +import { NAV_THEME, loadTheme } from "@/lib/theme"; import { SirouRouterProvider, useSirouRouter } from "@sirou/react-native"; import { refreshTokens } from "@/lib/api-middlewares"; -import { - NavigationContainer, - NavigationIndependentTree, - ThemeProvider, -} from "@react-navigation/native"; +import { ThemeProvider, NavigationIndependentTree } from "@react-navigation/native"; import { routes } from "@/lib/routes"; import { authGuard, guestGuard } from "@/lib/auth-guards"; import { useAuthStore } from "@/lib/auth-store"; import { useFonts } from "expo-font"; -import { api } from "@/lib/api"; import { useColorScheme } from "nativewind"; - -import { useSegments, useLocalSearchParams, router } from "expo-router"; +import { useSegments, useLocalSearchParams, useRouter } from "expo-router"; /** * GlobalGuard: Handles all routing security and authentication redirects. - * Reacts instantly to auth state changes to prevent unauthenticated users from seeing protected data. */ function GlobalGuard() { const segments = useSegments(); const params = useLocalSearchParams(); + const router = useRouter(); const isAuthenticated = useAuthStore((s) => s.isAuthenticated); const sirou = useSirouRouter(); const [isMounted, setIsMounted] = useState(false); @@ -45,7 +38,7 @@ function GlobalGuard() { }, []); useEffect(() => { - if (!isMounted) return; + if (!isMounted || !segments) return; const performGuardCheck = async () => { const routeName = segments.length > 0 ? segments.join("/") : "root"; @@ -54,21 +47,18 @@ function GlobalGuard() { segments[0] === "register" || segments[0] === "otp"; - // 1. FAST AUTH CHECK: If not authenticated and not on a public page, eject immediately. if (!isAuthenticated && !isAuthPage) { console.log(`[GlobalGuard] Unauthorized on "${routeName}". Ejecting...`); router.replace("/login"); return; } - // 2. GUEST CHECK: If authenticated and on an auth page, redirect to home. if (isAuthenticated && isAuthPage) { console.log(`[GlobalGuard] Authenticated user on auth page. Sending home.`); router.replace("/"); return; } - // 3. COMPLEX GUARDS: Permissions, roles, etc. handled by Sirou. try { const result = await (sirou as any).checkGuards(routeName, params); if (!result.allowed && result.redirect) { @@ -87,7 +77,7 @@ function GlobalGuard() { } /** - * SessionHeartbeat: Proactively refreshes tokens every 5 minutes and upon app foregrounding. + * SessionHeartbeat: Proactively refreshes tokens. */ function SessionHeartbeat() { const isAuthenticated = useAuthStore((s) => s.isAuthenticated); @@ -95,9 +85,7 @@ function SessionHeartbeat() { useEffect(() => { if (!isAuthenticated) return; - // Refresh every 5 minutes const INTERVAL_MS = 5 * 60 * 1000; - const performRefresh = async (reason: string) => { try { console.log(`[SessionHeartbeat] Refresh triggered by: ${reason}`); @@ -107,11 +95,9 @@ function SessionHeartbeat() { } }; - // 1. Initial/Interval Refresh - performRefresh("Mount"); // Refresh immediately on mount + performRefresh("Mount"); const interval = setInterval(() => performRefresh("Interval"), INTERVAL_MS); - // 2. Foreground Refresh (AppState listener) const subscription = AppState.addEventListener("change", (nextAppState) => { if (nextAppState === "active") { performRefresh("Foreground"); @@ -128,10 +114,11 @@ function SessionHeartbeat() { } export default function RootLayout() { - const { colorScheme } = useColorScheme(); - useRestoreTheme(); + const { colorScheme, setColorScheme } = useColorScheme(); const [isMounted, setIsMounted] = useState(false); const [hasHydrated, setHasHydrated] = useState(false); + const [isThemeRestored, setIsThemeRestored] = useState(false); + const [fontsLoaded] = useFonts({ "DMSans-Regular": require("../assets/fonts/DMSans-Regular.ttf"), "DMSans-Bold": require("../assets/fonts/DMSans-Bold.ttf"), @@ -146,124 +133,105 @@ export default function RootLayout() { useEffect(() => { setIsMounted(true); - + + // Auth Hydration const initializeAuth = async () => { if (useAuthStore.persist.hasHydrated()) { setHasHydrated(true); } else { - const unsub = useAuthStore.persist.onFinishHydration(() => { + useAuthStore.persist.onFinishHydration(() => { setHasHydrated(true); }); - return unsub; + } + }; + + // Theme Restoration + const initializeTheme = async () => { + try { + const savedTheme = await loadTheme(); + if (savedTheme && savedTheme !== "system") { + setColorScheme(savedTheme); + } + } catch (e) { + console.warn("[RootLayout] Theme restore failed:", e); + } finally { + setIsThemeRestored(true); } }; initializeAuth(); + initializeTheme(); }, []); - if (!isMounted || !hasHydrated || !fontsLoaded) { + const theme = useMemo(() => { + const isDark = colorScheme === "dark"; + return isDark ? NAV_THEME.dark : NAV_THEME.light; + }, [colorScheme]); + + if (!isMounted || !hasHydrated || !fontsLoaded || !isThemeRestored) { return ( - - + ); } return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); } diff --git a/app/invoices/[id].tsx b/app/invoices/[id].tsx index e4bc872..e2e2f21 100644 --- a/app/invoices/[id].tsx +++ b/app/invoices/[id].tsx @@ -38,6 +38,7 @@ import { StandardHeader } from "@/components/StandardHeader"; import { api, BASE_URL } from "@/lib/api"; import { toast } from "@/lib/toast-store"; import { useAuthStore } from "@/lib/auth-store"; +import { ActionModal } from "@/components/ActionModal"; // Android only SMS module let SmsAndroid: any = null; @@ -59,6 +60,7 @@ export default function InvoiceDetailScreen() { const [loading, setLoading] = useState(true); const [invoice, setInvoice] = useState(null); const [scanningSms, setScanningSms] = useState(false); + const [showDeleteModal, setShowDeleteModal] = useState(false); useEffect(() => { fetchInvoice(); @@ -82,28 +84,40 @@ export default function InvoiceDetailScreen() { const handleScanSms = async () => { if (Platform.OS !== "android") { - toast.error("Not Supported", "SMS scanning is only available on Android."); + toast.error( + "Not Supported", + "SMS scanning is only available on Android.", + ); return; } setScanningSms(true); try { const granted = await PermissionsAndroid.request( - PermissionsAndroid.PERMISSIONS.READ_SMS + PermissionsAndroid.PERMISSIONS.READ_SMS, ); - + if (granted !== PermissionsAndroid.RESULTS.GRANTED) { - toast.error("Permission Denied", "We need SMS access to verify payments."); + toast.error( + "Permission Denied", + "We need SMS access to verify payments.", + ); setScanningSms(false); return; } - toast.info("Scanning SMS", "Searching for bank messages from the last 30 minutes..."); + toast.info( + "Scanning SMS", + "Searching for bank messages from the last 30 minutes...", + ); // Simulate logic if native module is missing (Expo Go) if (!SmsAndroid) { setTimeout(() => { - toast.error("No Match", "No matching banking SMS found in the last 30 minutes."); + toast.error( + "No Match", + "No matching banking SMS found in the last 30 minutes.", + ); setScanningSms(false); }, 2000); return; @@ -130,7 +144,9 @@ export default function InvoiceDetailScreen() { // Search for amount or customer name in SMS body const match = messages.find((m: any) => { const body = m.body.toUpperCase(); - return body.includes(amountStr) || (custName && body.includes(custName)); + return ( + body.includes(amountStr) || (custName && body.includes(custName)) + ); }); if (match) { @@ -139,17 +155,24 @@ export default function InvoiceDetailScreen() { `We found a matching SMS proof for ${amountValue} ${invoice.currency}. Would you like to attach this to the invoice?`, [ { text: "No", style: "cancel" }, - { - text: "Attach SMS", - onPress: () => toast.success("Attached", "SMS proof linked to invoice successfully.") - } - ] + { + text: "Attach SMS", + onPress: () => + toast.success( + "Attached", + "SMS proof linked to invoice successfully.", + ), + }, + ], ); } else { - toast.error("No Match", "Could not find any matching banking SMS in the last 30 minutes."); + toast.error( + "No Match", + "Could not find any matching banking SMS in the last 30 minutes.", + ); } setScanningSms(false); - } + }, ); } catch (err) { toast.error("Error", "Something went wrong during SMS scan."); @@ -169,32 +192,25 @@ export default function InvoiceDetailScreen() { }; const handleDelete = async () => { - Alert.alert( - "Delete Invoice", - "Are you sure you want to delete this invoice? This action cannot be undone.", - [ - { text: "Cancel", style: "cancel" }, - { - text: "Delete", - style: "destructive", - onPress: async () => { - try { - setLoading(true); - const invoiceId = Array.isArray(id) ? id[0] : id; - await api.invoices.delete({ - params: { id: invoiceId as string }, - }); - toast.success("Success", "Invoice deleted successfully"); - nav.back(); - } catch (error) { - console.error("[InvoiceDetail] Delete Error:", error); - toast.error("Error", "Failed to delete invoice"); - setLoading(false); - } - }, - }, - ], - ); + setShowDeleteModal(true); + }; + + const confirmDelete = async () => { + try { + setLoading(true); + const invoiceId = Array.isArray(id) ? id[0] : id; + await api.invoices.delete({ + params: { id: invoiceId as string }, + }); + toast.success("Success", "Invoice deleted successfully"); + setShowDeleteModal(false); + nav.back(); + } catch (error) { + console.error("[InvoiceDetail] Delete Error:", error); + toast.error("Error", "Failed to delete invoice"); + setShowDeleteModal(false); + setLoading(false); + } }; if (loading) { @@ -229,7 +245,8 @@ export default function InvoiceDetailScreen() { // Robust data extraction const originalData = invoice.scannedData?.originalData || {}; - const items = (invoice.items?.length > 0 ? invoice.items : originalData.items) || []; + const items = + (invoice.items?.length > 0 ? invoice.items : originalData.items) || []; const taxAmountValue = Number( typeof invoice.taxAmount === "object" @@ -246,26 +263,43 @@ export default function InvoiceDetailScreen() { let amountValue = Number( typeof invoice.amount === "object" ? invoice.amount.value : invoice.amount, ); - + if (items.length > 0) { const itemsTotal = items.reduce( - (acc: number, item: any) => acc + (Number(item.total?.value || item.total) || 0), + (acc: number, item: any) => + acc + (Number(item.total?.value || item.total) || 0), 0, ); - if (itemsTotal > 0 && (amountValue === taxAmountValue || amountValue < itemsTotal)) { + if ( + itemsTotal > 0 && + (amountValue === taxAmountValue || amountValue < itemsTotal) + ) { amountValue = itemsTotal + taxAmountValue - discountValue; } } const subtotalValue = amountValue - taxAmountValue + discountValue; const statusColors = { - PAID: { bg: "bg-emerald-500/10", text: "text-emerald-500", dot: "bg-emerald-500" }, - PENDING: { bg: "bg-amber-500/10", text: "text-amber-500", dot: "bg-amber-500" }, + PAID: { + bg: "bg-emerald-500/10", + text: "text-emerald-500", + dot: "bg-emerald-500", + }, + PENDING: { + bg: "bg-amber-500/10", + text: "text-amber-500", + dot: "bg-amber-500", + }, DRAFT: { bg: "bg-blue-500/10", text: "text-blue-500", dot: "bg-blue-500" }, - DEFAULT: { bg: "bg-slate-500/10", text: "text-slate-500", dot: "bg-slate-500" }, + DEFAULT: { + bg: "bg-slate-500/10", + text: "text-slate-500", + dot: "bg-slate-500", + }, }; const status = (invoice.status || "PENDING").toUpperCase(); - const colors = statusColors[status as keyof typeof statusColors] || statusColors.DEFAULT; + const colors = + statusColors[status as keyof typeof statusColors] || statusColors.DEFAULT; return ( @@ -283,19 +317,17 @@ export default function InvoiceDetailScreen() { showsVerticalScrollIndicator={false} > - - - - {status} - - - - + Total Amount - {Number(amountValue).toLocaleString(undefined, { minimumFractionDigits: 2 })} + {Number(amountValue).toLocaleString(undefined, { + minimumFractionDigits: 2, + })} {invoice.currency || "ETB"} @@ -305,16 +337,24 @@ export default function InvoiceDetailScreen() { - + Date - {new Date(invoice.issueDate || invoice.createdAt).toLocaleDateString()} + {new Date( + invoice.issueDate || invoice.createdAt, + ).toLocaleDateString()} - + Due @@ -325,17 +365,24 @@ export default function InvoiceDetailScreen() { - - + + - + Client - - {invoice.customerName?.replace("Customer Name: ", "") || "Walking Client"} + + {invoice.customerName?.replace("Customer Name: ", "") || + "Walking Client"} @@ -346,32 +393,57 @@ export default function InvoiceDetailScreen() { Items - - {items.map((item: any, idx: number) => ( - - - {item.description} - - {Number(item.total?.value || item.total || 0).toLocaleString()} + {items.length === 0 ? ( + + No items found + + ) : ( + + {items.map((item: any, idx: number) => ( + + + + {item.description} + + + {Number( + item.total?.value || item.total || 0, + ).toLocaleString()} + + + + {item.quantity} x{" "} + {Number( + item.unitPrice?.value || item.unitPrice || 0, + ).toLocaleString()}{" "} + {invoice.currency} - - {item.quantity} x {Number(item.unitPrice?.value || item.unitPrice || 0).toLocaleString()} {invoice.currency} - - - ))} - + ))} + + )} - - - Subtotal - {subtotalValue.toLocaleString()} {invoice.currency} + + + + Subtotal + + + {subtotalValue.toLocaleString()} {invoice.currency} + - - Grand Total - {amountValue.toLocaleString()} {invoice.currency} + + + Grand Total + + + {amountValue.toLocaleString()} {invoice.currency} + @@ -379,7 +451,7 @@ export default function InvoiceDetailScreen() { - - + + setShowDeleteModal(false)} + onConfirm={confirmDelete} + title="Delete Invoice" + description="Are you sure you want to delete this invoice? This will remove all associated data and cannot be recovered." + confirmText="Delete" + confirmVariant="destructive" + icon={Trash2} + iconColor="#ef4444" + loading={loading} + /> ); } diff --git a/app/invoices/create.tsx b/app/invoices/create.tsx new file mode 100644 index 0000000..3cf39ed --- /dev/null +++ b/app/invoices/create.tsx @@ -0,0 +1,854 @@ +import React, { useState, useEffect } from "react"; +import { + View, + ScrollView, + Pressable, + TextInput, + StyleSheet, + ActivityIndicator, + useColorScheme, + Platform, +} from "react-native"; +import { Text } from "@/components/ui/text"; +import { Button } from "@/components/ui/button"; +import { + ArrowLeft, + Plus, + Calendar, + ChevronDown, + FileText, + Trash2, + DollarSign, + Send, + CalendarSearch, + Upload, +} from "@/lib/icons"; +import { ScreenWrapper } from "@/components/ScreenWrapper"; +import { useSirouRouter } from "@sirou/react-native"; +import { AppRoutes } from "@/lib/routes"; +import { Stack } from "expo-router"; +import { ShadowWrapper } from "@/components/ShadowWrapper"; +import { api, BASE_URL } from "@/lib/api"; +import { toast } from "@/lib/toast-store"; +import { useAuthStore } from "@/lib/auth-store"; +import * as ImagePicker from "expo-image-picker"; +import { PickerModal, SelectOption } from "@/components/PickerModal"; +import { CalendarGrid } from "@/components/CalendarGrid"; +import { StandardHeader } from "@/components/StandardHeader"; +import { getPlaceholderColor } from "@/lib/colors"; + +type Item = { id: number; description: string; qty: string; price: string }; + +const S = StyleSheet.create({ + input: { + height: 44, + paddingHorizontal: 12, + fontSize: 12, + fontWeight: "500", + borderRadius: 6, + borderWidth: 1, + }, + inputCenter: { + height: 44, + paddingHorizontal: 12, + fontSize: 14, + fontWeight: "500", + borderRadius: 6, + borderWidth: 1, + textAlign: "center", + }, +}); + +function useInputColors() { + const { colorScheme } = useColorScheme(); + const dark = colorScheme === "dark"; + return { + bg: dark ? "rgba(30,30,30,0.8)" : "rgba(241,245,249,0.2)", + border: dark ? "rgba(255,255,255,0.08)" : "rgba(203,213,225,0.6)", + text: dark ? "#f1f5f9" : "#0f172a", + placeholder: "rgba(100,116,139,0.45)", + }; +} + +function Field({ + label, + value, + onChangeText, + placeholder, + numeric = false, + center = false, + flex, + multiline = false, +}: { + label: string; + value: string; + onChangeText: (v: string) => void; + placeholder: string; + numeric?: boolean; + center?: boolean; + flex?: number; + multiline?: boolean; +}) { + const c = useInputColors(); + return ( + + + {label} + + + + ); +} + +export default function CreateInvoiceScreen() { + const nav = useSirouRouter(); + const [submitting, setSubmitting] = useState(false); + + // Form Fields + const [invoiceNumber, setInvoiceNumber] = useState(""); + const [customerName, setCustomerName] = useState(""); + const [customerEmail, setCustomerEmail] = useState(""); + const [customerPhone, setCustomerPhone] = useState(""); + const [description, setDescription] = useState(""); + const [currency, setCurrency] = useState("USD"); + const [type, setType] = useState("SALES"); + const [status, setStatus] = useState("DRAFT"); + const [taxAmount, setTaxAmount] = useState("0"); + const [discountAmount, setDiscountAmount] = useState("0"); + const [notes, setNotes] = useState(""); + + // Dates + const [issueDate, setIssueDate] = useState( + new Date().toISOString().split("T")[0], + ); + const [dueDate, setDueDate] = useState(""); + + // Items List + const [items, setItems] = useState([ + { id: 1, description: "", qty: "1", price: "" }, + ]); + + // Modal states + const [showCurrency, setShowCurrency] = useState(false); + const [showType, setShowType] = useState(false); + const [showStatus, setShowStatus] = useState(false); + const [showIssueDate, setShowIssueDate] = useState(false); + const [showDueDate, setShowDueDate] = useState(false); + + const [scanning, setScanning] = useState(false); + const token = useAuthStore((s) => s.token); + + const handlePickImage = async () => { + try { + const { status } = + await ImagePicker.requestMediaLibraryPermissionsAsync(); + if (status !== "granted") { + toast.error( + "Permission Denied", + "We need access to your gallery to upload invoices.", + ); + return; + } + + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ImagePicker.MediaTypeOptions.Images, + allowsEditing: true, + quality: 0.8, + }); + + if (!result.canceled && result.assets && result.assets.length > 0) { + const uri = result.assets[0].uri; + await handleProcessImage(uri); + } + } catch (e: any) { + console.error("[CreateInvoice] Pick Image Error:", e); + toast.error("Picker Failed", "Could not launch gallery picker."); + } + }; + + const handleProcessImage = async (uri: string) => { + setScanning(true); + toast.info("Processing...", "Uploading invoice to AI extraction engine."); + try { + const formData = new FormData(); + const fileExt = uri.split(".").pop() || "jpg"; + const fileName = `invoice-${Date.now()}.${fileExt}`; + const type = `image/${fileExt === "jpg" ? "jpeg" : fileExt}`; + + formData.append("file", { + uri: Platform.OS === "android" ? uri : uri.replace("file://", ""), + name: fileName, + type: type, + } as any); + + const response = await fetch(`${BASE_URL}scan/invoice`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/json", + }, + body: formData, + }); + + if (!response.ok) { + const err = await response + .json() + .catch(() => ({ message: "Scan processing failed." })); + throw new Error(err.message || "AI extraction failed."); + } + + const scanResult = await response.json(); + console.log("[CreateInvoice] Extracted scan result:", scanResult); + + if (!scanResult.success) { + throw new Error( + scanResult.message || "AI extraction was unsuccessful.", + ); + } + + toast.success("Success!", "Data extracted successfully."); + + const ocr = scanResult.data || {}; + if (ocr.invoiceNumber) setInvoiceNumber(ocr.invoiceNumber); + + let name = ocr.customerName?.trim() || ""; + name = name + .replace(/^Customer Name:\s*/i, "") + .replace(/^Bill To:\s*/i, ""); + if (name) setCustomerName(name); + + if (ocr.customerEmail) setCustomerEmail(ocr.customerEmail); + if (ocr.customerPhone) setCustomerPhone(ocr.customerPhone); + if (ocr.description) setDescription(ocr.description); + if (ocr.currency) setCurrency(ocr.currency); + if (ocr.taxAmount != null) setTaxAmount(String(ocr.taxAmount)); + + if (ocr.issueDate) { + try { + const formattedIssue = new Date(ocr.issueDate) + .toISOString() + .split("T")[0]; + setIssueDate(formattedIssue); + } catch (de) { + console.warn("[CreateInvoice] Issue Date parse error:", de); + } + } + if (ocr.dueDate) { + try { + const formattedDue = new Date(ocr.dueDate) + .toISOString() + .split("T")[0]; + setDueDate(formattedDue); + } catch (de) { + console.warn("[CreateInvoice] Due Date parse error:", de); + } + } + + if (ocr.items && ocr.items.length > 0) { + setItems( + ocr.items.map((item: any, idx: number) => ({ + id: idx + 1, + description: item.description || "Web Development Service", + qty: String(item.quantity || "1"), + price: String(item.unitPrice || item.total || "0"), + })), + ); + } + } catch (err: any) { + console.error("[CreateInvoice] Extraction Error:", err); + toast.error( + "Extraction Failed", + err.message || "AI was unable to extract invoice data.", + ); + } finally { + setScanning(false); + } + }; + + const colorScheme = useColorScheme(); + const isDark = colorScheme === "dark"; + const c = useInputColors(); + + // Auto-generate invoice number and set default due date on mount + useEffect(() => { + const year = new Date().getFullYear(); + const random = Math.floor(1000 + Math.random() * 9000); + setInvoiceNumber(`INV-${year}-${random}`); + + // Default Due Date: 30 days from now + const d = new Date(); + d.setDate(d.getDate() + 30); + setDueDate(d.toISOString().split("T")[0]); + }, []); + + const addItem = () => { + const newId = + items.length > 0 ? Math.max(...items.map((i) => i.id)) + 1 : 1; + setItems([...items, { id: newId, description: "", qty: "1", price: "" }]); + }; + + const removeItem = (id: number) => { + if (items.length > 1) { + setItems(items.filter((i) => i.id !== id)); + } + }; + + const updateField = (id: number, field: keyof Item, value: string) => { + setItems(items.map((i) => (i.id === id ? { ...i, [field]: value } : i))); + }; + + const subtotal = items.reduce( + (sum, item) => + sum + (parseFloat(item.qty) || 0) * (parseFloat(item.price) || 0), + 0, + ); + + const total = + subtotal + (parseFloat(taxAmount) || 0) - (parseFloat(discountAmount) || 0); + + const handleSubmit = async () => { + if (!invoiceNumber) { + toast.error("Validation Error", "Invoice Number is required"); + return; + } + if (!customerName) { + toast.error("Validation Error", "Customer Name is required"); + return; + } + + setSubmitting(true); + try { + const payload = { + invoiceNumber, + customerName, + customerEmail, + customerPhone, + amount: Number(total.toFixed(2)), + currency, + type, + status, + issueDate: new Date(issueDate).toISOString(), + dueDate: new Date(dueDate).toISOString(), + description: description || `Invoice for ${customerName}`, + notes, + taxAmount: parseFloat(taxAmount) || 0, + discountAmount: parseFloat(discountAmount) || 0, + isScanned: false, + scannedData: { + sellerTIN: "123456", + items: [], + }, + items: items.map((item) => ({ + description: item.description || "Web Development Service", + quantity: parseFloat(item.qty) || 0, + unitPrice: parseFloat(item.price) || 0, + total: Number( + ( + (parseFloat(item.qty) || 0) * (parseFloat(item.price) || 0) + ).toFixed(2), + ), + })), + }; + + await api.invoices.create({ body: payload }); + toast.success("Success", "Invoice created successfully!"); + nav.back(); + } catch (error: any) { + console.error("[CreateInvoice] Error:", error); + toast.error("Error", error.message || "Failed to create invoice"); + } finally { + setSubmitting(false); + } + }; + + const currencies = ["USD", "ETB", "EUR", "GBP", "KES", "ZAR"]; + const invoiceTypes = ["SALES", "PURCHASE", "SERVICE"]; + const invoiceStatuses = ["DRAFT", "PENDING", "PAID"]; + + return ( + + + + + + {/* Gallery Scanner */} + + {scanning ? ( + + ) : ( + + )} + + + {scanning ? "Extracting Data..." : "Scan From Gallery"} + + + Upload invoice image to automatically prefill form + + + + + {/* General Info */} + + + + + + + {/* Customer Details */} + + + + + + + + + + {/* Schedule & Configuration */} + + + + + + Issue Date + + setShowIssueDate(true)} + className="h-11 px-3 border border-border rounded-[6px] flex-row items-center justify-between" + style={{ backgroundColor: c.bg, borderColor: c.border }} + > + + {issueDate} + + + + + + + Due Date + + setShowDueDate(true)} + className="h-11 px-3 border border-border rounded-[6px] flex-row items-center justify-between" + style={{ backgroundColor: c.bg, borderColor: c.border }} + > + + {dueDate || "Select Date"} + + + + + + + + + + Currency + + setShowCurrency(true)} + className="h-11 px-3 border border-border rounded-[6px] flex-row items-center justify-between" + style={{ backgroundColor: c.bg, borderColor: c.border }} + > + + {currency} + + + + + + + + Type + + setShowType(true)} + className="h-11 px-3 border border-border rounded-[6px] flex-row items-center justify-between" + style={{ backgroundColor: c.bg, borderColor: c.border }} + > + + {type} + + + + + + + + + Status + + setShowStatus(true)} + className="h-11 px-3 border border-border rounded-[6px] flex-row items-center justify-between" + style={{ backgroundColor: c.bg, borderColor: c.border }} + > + + {status} + + + + + + {/* Billable Items */} + + + + + + Add Item + + + + + + {items.map((item, index) => ( + + + + Item {index + 1} + + {items.length > 1 && ( + removeItem(item.id)} hitSlop={8}> + + + )} + + + updateField(item.id, "description", v)} + /> + + + updateField(item.id, "qty", v)} + flex={1} + /> + updateField(item.id, "price", v)} + flex={2} + /> + + + Total + + + {currency} + {( + (parseFloat(item.qty) || 0) * + (parseFloat(item.price) || 0) + ).toFixed(2)} + + + + + ))} + + + {/* Totals & Taxes */} + + + + + Subtotal + + + {currency} {subtotal.toLocaleString()} + + + + + + + + + {/* Notes */} + + + + + + + {/* Footer */} + + + + Total Amount + + + {currency}{" "} + {total.toLocaleString("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} + + + + + + + + + + {/* Currency Modal */} + setShowCurrency(false)} + title="Select Currency" + > + {currencies.map((curr) => ( + { + setCurrency(v); + setShowCurrency(false); + }} + /> + ))} + + + {/* Type Modal */} + setShowType(false)} + title="Select Invoice Type" + > + {invoiceTypes.map((t) => ( + { + setType(v); + setShowType(false); + }} + /> + ))} + + + {/* Status Modal */} + setShowStatus(false)} + title="Select Invoice Status" + > + {invoiceStatuses.map((s) => ( + { + setStatus(v); + setShowStatus(false); + }} + /> + ))} + + + {/* Issue Date Modal */} + setShowIssueDate(false)} + title="Select Issue Date" + > + { + setIssueDate(v); + setShowIssueDate(false); + }} + /> + + + {/* Due Date Modal */} + setShowDueDate(false)} + title="Select Due Date" + > + { + setDueDate(v); + setShowDueDate(false); + }} + /> + + + ); +} + +function Label({ + children, + noMargin, +}: { + children: string; + noMargin?: boolean; +}) { + return ( + + {children} + + ); +} diff --git a/app/login.tsx b/app/login.tsx index 3dd5d6b..d38ba16 100644 --- a/app/login.tsx +++ b/app/login.tsx @@ -48,9 +48,7 @@ try { GoogleSignin.configure({ webClientId: - "1028200585478-cnp6lpmp75mj4b79pnijiefv5rl1etqi.apps.googleusercontent.com", - iosClientId: - "1028200585478-cnp6lpmp75mj4b79pnijiefv5rl1etqi.apps.googleusercontent.com", + "377689842258-42ofqmfhf61p03jamlr5nfi18ovqehvf.apps.googleusercontent.com", offlineAccess: true, }); } catch (e) { diff --git a/app/news/[id].tsx b/app/news/[id].tsx new file mode 100644 index 0000000..f5c0f2a --- /dev/null +++ b/app/news/[id].tsx @@ -0,0 +1,222 @@ +import React, { useState, useEffect } from "react"; +import { + View, + ScrollView, + ActivityIndicator, + useColorScheme, + Share, + Platform, +} from "react-native"; +import { useSirouRouter } from "@sirou/react-native"; +import { AppRoutes } from "@/lib/routes"; +import { Stack, useLocalSearchParams } from "expo-router"; +import { Text } from "@/components/ui/text"; +import { Button } from "@/components/ui/button"; +import { + Clock, + Share2, + Newspaper, + Calendar, + Tag, + AlertCircle, + Eye, +} from "@/lib/icons"; +import { ScreenWrapper } from "@/components/ScreenWrapper"; +import { StandardHeader } from "@/components/StandardHeader"; +import { api } from "@/lib/api"; +import { toast } from "@/lib/toast-store"; + +export default function NewsDetailScreen() { + const nav = useSirouRouter(); + const { id } = useLocalSearchParams(); + const colorScheme = useColorScheme(); + const isDark = colorScheme === "dark"; + + const [loading, setLoading] = useState(true); + const [news, setNews] = useState(null); + + const newsId = Array.isArray(id) ? id[0] : id; + + useEffect(() => { + if (newsId) { + fetchNewsDetail(); + } + }, [newsId]); + + const fetchNewsDetail = async () => { + try { + setLoading(true); + const data = await api.news.getById({ params: { id: newsId } }); + setNews(data); + } catch (error: any) { + console.error("[NewsDetail] Fetch error:", error); + toast.error("Error", "Failed to load news content."); + } finally { + setLoading(false); + } + }; + + const handleShare = async () => { + if (!news) return; + try { + await Share.share({ + title: news.title, + message: `${news.title}\n\n${news.content.substring(0, 100)}...\n\nRead more on Yaltopia Tickets.`, + }); + } catch (error) { + console.error("[NewsDetail] Share error:", error); + } + }; + + const getCategoryStyles = (category: string) => { + switch (category) { + case "ANNOUNCEMENT": + return { + bg: "bg-amber-500/10", + text: "text-amber-500", + dot: "bg-amber-500", + }; + case "UPDATE": + return { + bg: "bg-blue-500/10", + text: "text-blue-500", + dot: "bg-blue-500", + }; + case "MAINTENANCE": + return { bg: "bg-red-500/10", text: "text-red-500", dot: "bg-red-500" }; + default: + return { + bg: "bg-emerald-500/10", + text: "text-emerald-500", + dot: "bg-emerald-500", + }; + } + }; + + if (loading) { + return ( + + + + + + + + ); + } + + if (!news) { + return ( + + + + + + + Content Not Found + + + This news item might have been removed or is no longer available. + + + + + ); + } + + const styles = getCategoryStyles(news.category); + + return ( + + + + + + + {/* Metadata Row */} + + + + + {news.category} + + + + + + {news.viewCount || 0} Views + + + + + {/* Title */} + + {news.title} + + + {/* Author/Date Info */} + + + + + {new Date(news.publishedAt).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + })} + + + + + + + {new Date(news.publishedAt).toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + })} + + + + + {/* Content Body */} + + + {news.content} + + + + {/* Footer Actions */} + + + + + + + ); +} diff --git a/app/otp.tsx b/app/otp.tsx index 7c1a5d5..624c6af 100644 --- a/app/otp.tsx +++ b/app/otp.tsx @@ -144,7 +144,7 @@ export default function OtpScreen() { onKeyPress={(e) => handleKeyDown(e, i)} keyboardType="number-pad" maxLength={1} - className="w-10 h-10 border top-[2px] border-border rounded-[6px] text-center text-xl font-bold bg-card text-foreground" + className="w-10 h-10 border top-[2px] border-border rounded-[6px] text-center text-lg flex items-center justify-center font-bold bg-card text-foreground" placeholderTextColor={isDark ? "#475569" : "#cbd5e1"} /> ))} diff --git a/app/payments/[id].tsx b/app/payments/[id].tsx index 4709c59..8e6e5a6 100644 --- a/app/payments/[id].tsx +++ b/app/payments/[id].tsx @@ -33,6 +33,7 @@ import { StandardHeader } from "@/components/StandardHeader"; import { api, BASE_URL } from "@/lib/api"; import { useColorScheme } from "nativewind"; import { toast } from "@/lib/toast-store"; +import { ActionModal } from "@/components/ActionModal"; export default function PaymentDetailScreen() { const nav = useSirouRouter(); @@ -44,6 +45,7 @@ export default function PaymentDetailScreen() { const [loading, setLoading] = useState(true); const [deleting, setDeleting] = useState(false); const [matching, setMatching] = useState(false); + const [showDeleteModal, setShowDeleteModal] = useState(false); const paymentId = Array.isArray(id) ? id[0] : id; @@ -70,30 +72,23 @@ export default function PaymentDetailScreen() { }, [paymentId]); const handleDelete = async () => { - Alert.alert( - "Delete Payment", - "Are you sure you want to delete this payment record?", - [ - { text: "Cancel", style: "cancel" }, - { - text: "Delete", - style: "destructive", - onPress: async () => { - setDeleting(true); - try { - if (!paymentId) return; - await api.payments.delete({ params: { id: paymentId } }); - toast.success("Deleted", "Payment record has been removed."); - nav.back(); - } catch (err: any) { - toast.error("Error", err.message || "Failed to delete payment."); - } finally { - setDeleting(false); - } - }, - }, - ], - ); + setShowDeleteModal(true); + }; + + const confirmDelete = async () => { + setDeleting(true); + try { + if (!paymentId) return; + await api.payments.delete({ params: { id: paymentId } }); + toast.success("Deleted", "Payment record has been removed."); + setShowDeleteModal(false); + nav.back(); + } catch (err: any) { + toast.error("Error", err.message || "Failed to delete payment."); + setShowDeleteModal(false); + } finally { + setDeleting(false); + } }; const handleMatch = async () => { @@ -243,7 +238,7 @@ export default function PaymentDetailScreen() { > {/* Urgent Alerts */} {isFlagged && ( - + @@ -260,43 +255,9 @@ export default function PaymentDetailScreen() { {/* Hero Section */} - {/* Status Badges */} - - - - - {payment.invoiceId ? "Matched" : "Pending Match"} - - - - {isFailed && ( - - - - Verify Failed - - - )} - - {isScanned && ( - - - - Scanned - - - )} - - Total Transaction Amount @@ -318,7 +279,7 @@ export default function PaymentDetailScreen() { Merchant @@ -335,7 +296,7 @@ export default function PaymentDetailScreen() { Provider @@ -353,8 +314,8 @@ export default function PaymentDetailScreen() { {/* Sender / Payer Box */} - - + + @@ -415,7 +376,7 @@ export default function PaymentDetailScreen() { {scanned?.imageUrl && ( + + setShowDeleteModal(false)} + onConfirm={confirmDelete} + title="Delete Request" + description="Are you sure you want to permanently delete this payment request? This action cannot be reversed." + confirmText="Delete" + confirmVariant="destructive" + icon={Trash2} + iconColor="#ef4444" + loading={deleting} + /> ); } diff --git a/app/proforma/[id].tsx b/app/proforma/[id].tsx index 81f0d81..5478aa4 100644 --- a/app/proforma/[id].tsx +++ b/app/proforma/[id].tsx @@ -196,17 +196,6 @@ export default function ProformaDetailScreen() { > {/* Modern Hero Area */} - - - - {status} - - - - + Net Price @@ -345,7 +334,7 @@ export default function ProformaDetailScreen() { {taxAmountValue > 0 && ( - + Estimated Tax @@ -356,7 +345,7 @@ export default function ProformaDetailScreen() { )} {discountValue > 0 && ( - + Applicable Discount @@ -366,19 +355,13 @@ export default function ProformaDetailScreen() { )} - + - + Estimated Total - - Valid as of today - - + {amountValue.toLocaleString()} {proforma.currency} @@ -404,7 +387,7 @@ export default function ProformaDetailScreen() { + + + {/* Body */} + + + {description} + + + + {/* Footer */} + + + + + + + + ); +} diff --git a/components/ShadowWrapper.tsx b/components/ShadowWrapper.tsx index 3dc1625..8430d0a 100644 --- a/components/ShadowWrapper.tsx +++ b/components/ShadowWrapper.tsx @@ -47,6 +47,7 @@ export function ShadowWrapper({ } : {}; + // Standard React Native view to avoid any potential interop issues in deep trees during re-renders return (