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() {
-
-
+
- Delete
+
+ Delete
+
+
+ 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,
+ })}
+
+
+
+ nav.back()}
+ disabled={submitting}
+ >
+
+ Discard
+
+
+
+ {submitting ? (
+
+ ) : (
+ <>
+
+
+ Create Invoice
+
+ >
+ )}
+
+
+
+
+
+ {/* 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.
+
+ nav.back()}>
+
+ Go Back
+
+
+
+
+ );
+ }
+
+ 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 */}
+
+
+
+
+ Share Article
+
+
+
+
+
+
+ );
+}
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 && (
Linking.openURL(
`${BASE_URL}${scanned.imageUrl.startsWith("/") ? scanned.imageUrl.substring(1) : scanned.imageUrl}`,
@@ -434,7 +395,7 @@ export default function PaymentDetailScreen() {
)}
{!payment.invoiceId && !isFailed && (
@@ -454,7 +415,7 @@ export default function PaymentDetailScreen() {
@@ -464,13 +425,26 @@ export default function PaymentDetailScreen() {
<>
- Terminate Record
+ Delete Request
>
)}
+
+ 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() {
nav.go("proforma/edit", { id: proforma.id })}
>
@@ -414,7 +397,7 @@ export default function ProformaDetailScreen() {
diff --git a/components/ActionModal.tsx b/components/ActionModal.tsx
new file mode 100644
index 0000000..2db88ec
--- /dev/null
+++ b/components/ActionModal.tsx
@@ -0,0 +1,106 @@
+import React from "react";
+import { View, Modal, Pressable, StyleSheet } from "react-native";
+import { Text } from "./ui/text";
+import { Button } from "./ui/button";
+import { LucideIcon } from "lucide-react-native";
+import { X } from "@/lib/icons";
+import { useColorScheme } from "nativewind";
+
+interface ActionModalProps {
+ visible: boolean;
+ onClose: () => void;
+ onConfirm: () => void;
+ title: string;
+ description: string;
+ confirmText?: string;
+ cancelText?: string;
+ confirmVariant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link";
+ icon?: LucideIcon;
+ iconColor?: string;
+ loading?: boolean;
+}
+
+export function ActionModal({
+ visible,
+ onClose,
+ onConfirm,
+ title,
+ description,
+ confirmText = "Confirm",
+ cancelText = "Cancel",
+ confirmVariant = "default",
+ icon: Icon,
+ iconColor = "#ea580c",
+ loading = false,
+}: ActionModalProps) {
+ const { colorScheme } = useColorScheme();
+ const isDark = colorScheme === "dark";
+
+ return (
+
+
+ e.stopPropagation()}
+ >
+ {/* Header */}
+
+
+ {Icon && (
+
+
+
+ )}
+
+ {title}
+
+
+
+
+
+
+
+ {/* Body */}
+
+
+ {description}
+
+
+
+ {/* Footer */}
+
+
+
+ {cancelText}
+
+
+
+
+ {confirmText}
+
+
+
+
+
+
+ );
+}
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 (