reactにコンバートした sonnetで

This commit is contained in:
joe 2026-02-15 03:24:37 +09:00
parent b11ae890ce
commit 7aeec4225a
31 changed files with 11882 additions and 101 deletions

10
.antigravityignore Normal file
View file

@ -0,0 +1,10 @@
# AIに読ませても枠を溶かすだけのゴミたち
.dart_tool/
build/
ios/
android/
web/
*.lock
*.svg
assets/
.env

View file

@ -220,6 +220,33 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
), ),
if (!_isEditing) ...[ if (!_isEditing) ...[
IconButton(icon: const Icon(Icons.grid_on), onPressed: _exportCsv, tooltip: "CSV出力"), IconButton(icon: const Icon(Icons.grid_on), onPressed: _exportCsv, tooltip: "CSV出力"),
if (widget.isUnlocked)
IconButton(
icon: const Icon(Icons.copy),
tooltip: "コピーして新規作成",
onPressed: () async {
// IDを生成して複製
final newId = DateTime.now().millisecondsSinceEpoch.toString();
final duplicateInvoice = _currentInvoice.copyWith(
id: newId,
date: DateTime.now(),
isDraft: true, //
);
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => InvoiceInputForm(
onInvoiceGenerated: (inv, path) {
//
// HistoryScreen側で対応済み
},
existingInvoice: duplicateInvoice,
),
),
);
},
),
if (widget.isUnlocked) if (widget.isUnlocked)
IconButton( IconButton(
icon: const Icon(Icons.edit_note), // icon: const Icon(Icons.edit_note), //

View file

@ -99,7 +99,7 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
leading: const BackButton(), // // leading removed
title: GestureDetector( title: GestureDetector(
onLongPress: () { onLongPress: () {
Navigator.push( Navigator.push(
@ -137,34 +137,6 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
}, },
tooltip: "ソート切り替え", tooltip: "ソート切り替え",
), ),
IconButton(
icon: Icon(Icons.date_range, color: (_startDate != null || _endDate != null) ? Colors.orange : Colors.white),
onPressed: () async {
final picked = await showDateRangePicker(
context: context,
initialDateRange: (_startDate != null && _endDate != null)
? DateTimeRange(start: _startDate!, end: _endDate!)
: null,
firstDate: DateTime(2020),
lastDate: DateTime.now().add(const Duration(days: 365)),
);
if (picked != null) {
setState(() {
_startDate = picked.start;
_endDate = picked.end;
});
_applyFilterAndSort();
} else if (_startDate != null) {
//
setState(() {
_startDate = null;
_endDate = null;
});
_applyFilterAndSort();
}
},
tooltip: "日付範囲で絞り込み",
),
IconButton( IconButton(
icon: const Icon(Icons.refresh), icon: const Icon(Icons.refresh),
onPressed: _loadData, onPressed: _loadData,
@ -260,9 +232,17 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
itemBuilder: (context, index) { itemBuilder: (context, index) {
final invoice = _filteredInvoices[index]; final invoice = _filteredInvoices[index];
return ListTile( return ListTile(
tileColor: invoice.isDraft ? Colors.orange.shade50 : null, //
leading: CircleAvatar( leading: CircleAvatar(
backgroundColor: _isUnlocked ? Colors.indigo.shade100 : Colors.grey.shade200, backgroundColor: invoice.isDraft
child: Icon(Icons.description_outlined, color: _isUnlocked ? Colors.indigo : Colors.grey), ? Colors.orange.shade100
: (_isUnlocked ? Colors.indigo.shade100 : Colors.grey.shade200),
child: Icon(
invoice.isDraft ? Icons.edit_note : Icons.description_outlined,
color: invoice.isDraft
? Colors.orange
: (_isUnlocked ? Colors.indigo : Colors.grey),
),
), ),
title: Column( title: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -342,6 +322,7 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
builder: (context) => InvoiceFlowScreen(onComplete: _loadData), builder: (context) => InvoiceFlowScreen(onComplete: _loadData),
), ),
); );
_loadData();
}, },
label: const Text("新規伝票作成"), label: const Text("新規伝票作成"),
icon: const Icon(Icons.add), icon: const Icon(Icons.add),

View file

@ -38,6 +38,7 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
DateTime _selectedDate = DateTime.now(); // : DateTime _selectedDate = DateTime.now(); // :
bool _isDraft = false; // : bool _isDraft = false; // :
final TextEditingController _subjectController = TextEditingController(); // final TextEditingController _subjectController = TextEditingController(); //
bool _isSaving = false; //
String _status = "取引先と商品を入力してください"; String _status = "取引先と商品を入力してください";
// //
@ -121,26 +122,31 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
documentType: _documentType, documentType: _documentType,
customerFormalNameSnapshot: _selectedCustomer!.formalName, customerFormalNameSnapshot: _selectedCustomer!.formalName,
subject: _subjectController.text.isNotEmpty ? _subjectController.text : null, // subject: _subjectController.text.isNotEmpty ? _subjectController.text : null, //
notes: _includeTax ? "(消費税 ${(_taxRate * 100).toInt()}% 込み)" : "(非課税)", notes: _includeTax ? "(消費税 ${(_taxRate * 100).toInt()}% 込み)" : null,
latitude: pos?.latitude, latitude: pos?.latitude,
longitude: pos?.longitude, longitude: pos?.longitude,
isDraft: _isDraft, // isDraft: _isDraft, //
); );
setState(() => _isSaving = true);
// PDF生成有無に関わらず
if (generatePdf) { if (generatePdf) {
setState(() => _status = "PDFを生成中..."); setState(() => _status = "PDFを生成中...");
final path = await generateInvoicePdf(invoice); final path = await generateInvoicePdf(invoice);
if (path != null) { if (path != null) {
final updatedInvoice = invoice.copyWith(filePath: path); final updatedInvoice = invoice.copyWith(filePath: path);
await _repository.saveInvoice(updatedInvoice); await _repository.saveInvoice(updatedInvoice);
widget.onInvoiceGenerated(updatedInvoice, path); if (mounted) widget.onInvoiceGenerated(updatedInvoice, path);
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("伝票を保存し、PDFを生成しました"))); if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("伝票を保存し、PDFを生成しました")));
} }
} else { } else {
await _repository.saveInvoice(invoice); await _repository.saveInvoice(invoice);
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("伝票を保存しましたPDF未生成"))); if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("伝票を保存しましたPDF未生成")));
Navigator.pop(context); // if (mounted) Navigator.pop(context);
} }
if (mounted) setState(() => _isSaving = false);
} }
void _showPreview() { void _showPreview() {
@ -196,10 +202,12 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
backgroundColor: themeColor, backgroundColor: themeColor,
appBar: AppBar( appBar: AppBar(
leading: const BackButton(), leading: const BackButton(),
title: Text(_isDraft ? "伝票作成 (下書き)" : "販売アシスト1号 V1.5.04"), title: Text(_isDraft ? "伝票作成 (下書き)" : "販売アシスト1号 V1.5.05"),
backgroundColor: _isDraft ? Colors.black87 : Colors.blueGrey, backgroundColor: _isDraft ? Colors.black87 : Colors.blueGrey,
), ),
body: Column( body: Stack(
children: [
Column(
children: [ children: [
Expanded( Expanded(
child: SingleChildScrollView( child: SingleChildScrollView(
@ -207,7 +215,7 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildDraftToggle(), // _buildDraftToggle(),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildDocumentTypeSection(), _buildDocumentTypeSection(),
const SizedBox(height: 16), const SizedBox(height: 16),
@ -215,11 +223,11 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
const SizedBox(height: 16), const SizedBox(height: 16),
_buildCustomerSection(), _buildCustomerSection(),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildSubjectSection(textColor), // _buildSubjectSection(textColor),
const SizedBox(height: 20), const SizedBox(height: 20),
_buildItemsSection(fmt), _buildItemsSection(fmt),
const SizedBox(height: 20), const SizedBox(height: 20),
_buildExperimentalSection(), _buildTaxSettings(),
const SizedBox(height: 20), const SizedBox(height: 20),
_buildSummarySection(fmt), _buildSummarySection(fmt),
const SizedBox(height: 20), const SizedBox(height: 20),
@ -231,6 +239,22 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
_buildBottomActionBar(), _buildBottomActionBar(),
], ],
), ),
if (_isSaving)
Container(
color: Colors.black54,
child: const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(color: Colors.white),
SizedBox(height: 16),
Text("保存中...", style: TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)),
],
),
),
),
],
),
); );
} }
@ -362,9 +386,31 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text("${fmt.format(item.unitPrice * item.quantity)}", style: const TextStyle(fontWeight: FontWeight.bold)), Text("${fmt.format(item.unitPrice * item.quantity)}", style: const TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(width: 8),
if (idx > 0)
IconButton(
icon: const Icon(Icons.arrow_upward, size: 20),
onPressed: () => setState(() {
final temp = _items[idx];
_items[idx] = _items[idx - 1];
_items[idx - 1] = temp;
}),
tooltip: "上へ",
),
if (idx < _items.length - 1)
IconButton(
icon: const Icon(Icons.arrow_downward, size: 20),
onPressed: () => setState(() {
final temp = _items[idx];
_items[idx] = _items[idx + 1];
_items[idx + 1] = temp;
}),
tooltip: "下へ",
),
IconButton( IconButton(
icon: const Icon(Icons.remove_circle_outline, color: Colors.redAccent), icon: const Icon(Icons.remove_circle_outline, color: Colors.redAccent),
onPressed: () => setState(() => _items.removeAt(idx)), onPressed: () => setState(() => _items.removeAt(idx)),
tooltip: "削除",
), ),
], ],
), ),
@ -386,6 +432,23 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
], ],
), ),
actions: [ actions: [
TextButton.icon(
icon: const Icon(Icons.search, size: 18),
label: const Text("マスター参照"),
onPressed: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => ProductPickerModal(
onItemSelected: (selected) {
descCtrl.text = selected.description;
priceCtrl.text = selected.unitPrice.toString();
Navigator.pop(context); // close picker
},
),
);
},
),
TextButton(onPressed: () => Navigator.pop(context), child: const Text("キャンセル")), TextButton(onPressed: () => Navigator.pop(context), child: const Text("キャンセル")),
ElevatedButton( ElevatedButton(
onPressed: () { onPressed: () {
@ -411,19 +474,15 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
); );
} }
Widget _buildExperimentalSection() { Widget _buildTaxSettings() {
return Container( return Column(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(color: Colors.orange.shade50, borderRadius: BorderRadius.circular(12)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Text("実験的オプション", style: TextStyle(fontWeight: FontWeight.bold, color: Colors.orange)),
const SizedBox(height: 8),
Row( Row(
children: [ children: [
if (_includeTax) ...[ if (_includeTax) ...[
const Text("消費税: "), const Text("消費税率: ", style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(width: 8),
ChoiceChip( ChoiceChip(
label: const Text("10%"), label: const Text("10%"),
selected: _taxRate == 0.10, selected: _taxRate == 0.10,
@ -436,17 +495,16 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
onSelected: (val) => setState(() => _taxRate = 0.08), onSelected: (val) => setState(() => _taxRate = 0.08),
), ),
] else ] else
const Text("(税別設定のため設定なし)", style: TextStyle(color: Colors.grey)), const Text("消費税設定: 非課税", style: TextStyle(fontWeight: FontWeight.bold, color: Colors.grey)),
const Spacer(), const Spacer(),
Switch( Switch(
value: _includeTax, value: _includeTax,
onChanged: (val) => setState(() => _includeTax = val), onChanged: (val) => setState(() => _includeTax = val),
), ),
Text(_includeTax ? "税込表示" : "非課税"), Text(_includeTax ? "税込計算" : "非課税"),
], ],
), ),
], ],
),
); );
} }

41
react_native_app/.gitignore vendored Normal file
View file

@ -0,0 +1,41 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
# generated native folders
/ios
/android

133
react_native_app/App.tsx Normal file
View file

@ -0,0 +1,133 @@
// Version: 2026-02-15
/**
* Main App Entry Point
* Basic navigation setup
*/
import React, { useEffect, useState } from 'react';
import { StyleSheet, Text, View, ActivityIndicator } from 'react-native';
import { StatusBar } from 'expo-status-bar';
import { NavigationContainer } from '@react-navigation/native';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { initDatabase } from './src/services/database';
const Tab = createBottomTabNavigator();
// Placeholder screens
const HomeScreen = () => (
<View style={styles.screen}>
<Text style={styles.title}></Text>
<Text style={styles.subtitle}>1 v2.0.0</Text>
<Text style={styles.info}>React Native (Expo) </Text>
</View>
);
const CreateScreen = () => (
<View style={styles.screen}>
<Text style={styles.title}></Text>
<Text style={styles.subtitle}>Coming Soon...</Text>
</View>
);
const SettingsScreen = () => (
<View style={styles.screen}>
<Text style={styles.title}></Text>
<Text style={styles.subtitle}>Coming Soon...</Text>
</View>
);
export default function App() {
const [isReady, setIsReady] = useState(false);
useEffect(() => {
const initialize = async () => {
try {
await initDatabase();
console.log('Database initialized successfully');
} catch (error) {
console.error('Failed to initialize database:', error);
} finally {
setIsReady(true);
}
};
initialize();
}, []);
if (!isReady) {
return (
<View style={styles.loading}>
<ActivityIndicator size="large" color="#607D8B" />
<Text style={styles.loadingText}>...</Text>
</View>
);
}
return (
<NavigationContainer>
<Tab.Navigator
screenOptions={{
tabBarActiveTintColor: '#607D8B',
tabBarInactiveTintColor: '#999',
headerStyle: {
backgroundColor: '#607D8B',
},
headerTintColor: '#fff',
}}
>
<Tab.Screen
name="Home"
component={HomeScreen}
options={{ title: '履歴' }}
/>
<Tab.Screen
name="Create"
component={CreateScreen}
options={{ title: '新規作成' }}
/>
<Tab.Screen
name="Settings"
component={SettingsScreen}
options={{ title: '設定' }}
/>
</Tab.Navigator>
<StatusBar style="auto" />
</NavigationContainer>
);
}
const styles = StyleSheet.create({
loading: {
flex: 1,
backgroundColor: '#fff',
alignItems: 'center',
justifyContent: 'center',
},
loadingText: {
marginTop: 16,
fontSize: 16,
color: '#666',
},
screen: {
flex: 1,
backgroundColor: '#f5f5f5',
alignItems: 'center',
justifyContent: 'center',
padding: 20,
},
title: {
fontSize: 24,
fontWeight: 'bold',
marginBottom: 8,
color: '#333',
},
subtitle: {
fontSize: 16,
color: '#666',
marginBottom: 4,
},
info: {
fontSize: 14,
color: '#999',
},
});

View file

@ -0,0 +1,74 @@
# React Native版 販売アシスト1号
// Version: 2026-02-15
FlutterからReact Native (Expo)に移行した請求書管理アプリケーション
## セットアップ
```bash
cd react_native_app
npm install
```
## 開発サーバー起動
```bash
npm start
```
## ビルド
- Android: `npm run android`
- iOS: `npm run ios` (macOS required)
- Web: `npm run web`
## 実装済み機能
### ✅ Phase 1-3完了
- TypeScript型定義(Invoice, Customer, Product, Company)
- SQLiteデータベースサービス(expo-sqlite)
- リポジトリ層(CRUD操作)
- 基本UIコンポーネント
- ナビゲーション(React Navigation)
### 🚧 Phase 4: 残りのタスク
- 請求書入力画面の完成
- 詳細画面の実装
- PDF生成機能
- Bluetooth印刷機能
- マスター管理画面
- GPS機能
## プロジェクト構造
```
react_native_app/
├── App.tsx # エントリーポイント
├── src/
│ ├── models/ # TypeScript型定義
│ ├── services/
│ │ ├── database.ts # SQLite
│ │ └── repositories/ # データアクセス層
│ └── components/
│ └── InvoiceForm/ # UIコンポーネント
└── assets/
└── fonts/
└── ipaexg.ttf # 日本語フォント
```
## 移行状況
Flutterコードベース → React Native移行率: **約40%完了**
### 完了
- [x] データモデル
- [x] データベース層
- [x] リポジトリ層
- [x] 基本コンポーネント
### 未完了
- [ ] 全画面の実装
- [ ] PDF生成
- [ ] 印刷機能
- [ ] バーコードスキャン
- [ ] GPS連携

34
react_native_app/app.json Normal file
View file

@ -0,0 +1,34 @@
{
"expo": {
"name": "react_native_app",
"slug": "react_native_app",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"newArchEnabled": true,
"splash": {
"image": "./assets/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"ios": {
"supportsTablet": true
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff"
},
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false
},
"web": {
"favicon": "./assets/favicon.png"
},
"plugins": [
"expo-sqlite",
"expo-barcode-scanner"
]
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View file

@ -0,0 +1,8 @@
import { registerRootComponent } from 'expo';
import App from './App';
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
// It also ensures that whether you load the app in Expo Go or in a native build,
// the environment is set up appropriately
registerRootComponent(App);

9805
react_native_app/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,44 @@
{
"name": "react_native_app",
"version": "1.0.0",
"main": "index.ts",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web"
},
"dependencies": {
"@react-native-picker/picker": "^2.11.4",
"@react-navigation/bottom-tabs": "^7.13.0",
"@react-navigation/native": "^7.1.28",
"@react-pdf/renderer": "^4.3.2",
"date-fns": "^4.1.0",
"expo": "~54.0.33",
"expo-barcode-scanner": "^13.0.1",
"expo-constants": "~18.0.13",
"expo-contacts": "~15.0.11",
"expo-crypto": "~15.0.8",
"expo-file-system": "~19.0.21",
"expo-image-picker": "~17.0.10",
"expo-linking": "~8.0.11",
"expo-location": "~19.0.8",
"expo-permissions": "^14.4.0",
"expo-print": "~15.0.8",
"expo-sharing": "~14.0.8",
"expo-sqlite": "~16.0.10",
"expo-status-bar": "~3.0.9",
"react": "19.1.0",
"react-native": "0.81.5",
"react-native-bluetooth-escpos-printer": "^0.0.5",
"react-native-safe-area-context": "^5.6.2",
"react-native-screens": "^4.23.0",
"react-native-signature-canvas": "^5.0.2",
"uuid": "^13.0.0"
},
"devDependencies": {
"@types/react": "~19.1.0",
"typescript": "~5.9.2"
},
"private": true
}

View file

@ -0,0 +1,66 @@
// Version: 2026-02-15
/**
* Document Type Selection Component
* Migrated from Flutter _buildDocumentTypeSection()
*/
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { Picker } from '@react-native-picker/picker';
import { DocumentType, getDocumentTypeName } from '../../models';
interface DocumentTypeSectionProps {
value: DocumentType;
onChange: (type: DocumentType) => void;
}
export const DocumentTypeSection: React.FC<DocumentTypeSectionProps> = ({
value,
onChange,
}) => {
const documentTypes: DocumentType[] = [
'invoice',
'quotation',
'delivery',
'receipt',
'statement',
];
return (
<View style={styles.container}>
<Text style={styles.label}></Text>
<Picker
selectedValue={value}
onValueChange={(itemValue) => onChange(itemValue as DocumentType)}
style={styles.picker}
>
{documentTypes.map((type) => (
<Picker.Item
key={type}
label={getDocumentTypeName(type)}
value={type}
/>
))}
</Picker>
</View>
);
};
const styles = StyleSheet.create({
container: {
padding: 16,
backgroundColor: '#fff',
borderRadius: 8,
marginBottom: 16,
},
label: {
fontSize: 14,
fontWeight: '600',
marginBottom: 8,
color: '#333',
},
picker: {
height: 50,
width: '100%',
},
});

View file

@ -0,0 +1,188 @@
// Version: 2026-02-15
/**
* Items Section Component
* Migrated from Flutter _buildItemsSection()
*/
import React from 'react';
import { View, Text, TextInput, TouchableOpacity, StyleSheet, ScrollView } from 'react-native';
import { InvoiceItem, createInvoiceItem } from '../../models';
interface ItemsSectionProps {
items: InvoiceItem[];
onItemsChange: (items: InvoiceItem[]) => void;
}
export const ItemsSection: React.FC<ItemsSectionProps> = ({
items,
onItemsChange,
}) => {
const addItem = () => {
onItemsChange([...items, createInvoiceItem()]);
};
const updateItem = (index: number, field: keyof InvoiceItem, value: string | number) => {
const newItems = [...items];
newItems[index] = { ...newItems[index], [field]: value };
// Recalculate subtotal
if (field === 'quantity' || field === 'unitPrice') {
newItems[index].subtotal =
newItems[index].quantity * newItems[index].unitPrice;
}
onItemsChange(newItems);
};
const removeItem = (index: number) => {
const newItems = items.filter((_, i) => i !== index);
onItemsChange(newItems);
};
return (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}></Text>
<TouchableOpacity style={styles.addButton} onPress={addItem}>
<Text style={styles.addButtonText}>+ </Text>
</TouchableOpacity>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View>
{/* Table Header */}
<View style={styles.tableRow}>
<Text style={styles.headerCell}></Text>
<Text style={[styles.headerCell, styles.quantityHeader]}></Text>
<Text style={[styles.headerCell, styles.priceHeader]}></Text>
<Text style={[styles.headerCell, styles.subtotalHeader]}></Text>
<Text style={[styles.headerCell, styles.actionHeader]}></Text>
</View>
{/* Table Body */}
{items.map((item, index) => (
<View key={index} style={styles.tableRow}>
<TextInput
style={styles.descriptionInput}
value={item.description}
onChangeText={(text) => updateItem(index, 'description', text)}
placeholder="品目名"
/>
<TextInput
style={styles.quantityInput}
value={item.quantity.toString()}
onChangeText={(text) =>
updateItem(index, 'quantity', parseFloat(text) || 0)
}
keyboardType="numeric"
/>
<TextInput
style={styles.priceInput}
value={item.unitPrice.toString()}
onChangeText={(text) =>
updateItem(index, 'unitPrice', parseFloat(text) || 0)
}
keyboardType="numeric"
/>
<Text style={styles.subtotalText}>¥{item.subtotal.toLocaleString()}</Text>
<TouchableOpacity onPress={() => removeItem(index)}>
<Text style={styles.deleteButton}>🗑</Text>
</TouchableOpacity>
</View>
))}
</View>
</ScrollView>
</View>
);
};
const styles = StyleSheet.create({
container: {
padding: 16,
backgroundColor: '#fff',
borderRadius: 8,
marginBottom: 16,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 12,
},
title: {
fontSize: 16,
fontWeight: '600',
color: '#333',
},
addButton: {
backgroundColor: '#4CAF50',
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 4,
},
addButtonText: {
color: '#fff',
fontWeight: '600',
},
tableRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 8,
},
headerCell: {
fontSize: 12,
fontWeight: '600',
color: '#666',
padding: 8,
},
quantityHeader: {
width: 80,
},
priceHeader: {
width: 100,
},
subtotalHeader: {
width: 100,
},
actionHeader: {
width: 50,
},
descriptionInput: {
flex: 1,
minWidth: 200,
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 4,
padding: 8,
marginRight: 8,
},
quantityInput: {
width: 80,
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 4,
padding: 8,
marginRight: 8,
textAlign: 'right',
},
priceInput: {
width: 100,
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 4,
padding: 8,
marginRight: 8,
textAlign: 'right',
},
subtotalText: {
width: 100,
padding: 8,
marginRight: 8,
textAlign: 'right',
fontWeight: '600',
},
deleteButton: {
fontSize: 20,
padding: 8,
},
});

View file

@ -0,0 +1,94 @@
// Version: 2026-02-15
/**
* Summary Section Component
* Migrated from Flutter _buildSummarySection()
*/
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { TaxDisplayMode } from '../../models';
interface SummarySectionProps {
subtotal: number;
tax: number;
totalAmount: number;
taxDisplayMode: TaxDisplayMode;
}
export const SummarySection: React.FC<SummarySectionProps> = ({
subtotal,
tax,
totalAmount,
taxDisplayMode,
}) => {
const SummaryRow: React.FC<{
label: string;
value: string;
isTotal?: boolean;
}> = ({ label, value, isTotal = false }) => (
<View style={styles.row}>
<Text style={[styles.label, isTotal && styles.totalLabel]}>{label}</Text>
<Text style={[styles.value, isTotal && styles.totalValue]}>{value}</Text>
</View>
);
return (
<View style={styles.container}>
<SummaryRow label="小計" value={`¥${subtotal.toLocaleString()}`} />
{taxDisplayMode === 'normal' && (
<SummaryRow label="消費税" value={`¥${tax.toLocaleString()}`} />
)}
{taxDisplayMode === 'text_only' && (
<SummaryRow label="消費税" value="(税抜)" />
)}
<View style={styles.separator} />
<SummaryRow
label={taxDisplayMode === 'hidden' ? '合計' : '合計(税込)'}
value={`¥${totalAmount.toLocaleString()}`}
isTotal
/>
</View>
);
};
const styles = StyleSheet.create({
container: {
padding: 16,
backgroundColor: '#f5f5f5',
borderRadius: 8,
marginBottom: 16,
},
row: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 8,
},
label: {
fontSize: 14,
color: '#666',
},
value: {
fontSize: 14,
fontWeight: '600',
color: '#333',
},
totalLabel: {
fontSize: 18,
fontWeight: '700',
color: '#000',
},
totalValue: {
fontSize: 18,
fontWeight: '700',
color: '#2196F3',
},
separator: {
height: 1,
backgroundColor: '#ddd',
marginVertical: 8,
},
});

View file

@ -0,0 +1,28 @@
// Version: 2026-02-15
/**
* Company information model
* Migrated from Flutter lib/models/company_model.dart
*/
import { TaxDisplayMode } from './invoice';
export interface CompanyInfo {
name: string;
postalCode?: string;
address?: string;
tel?: string;
fax?: string;
email?: string;
representativeName?: string;
taxRate: number;
taxDisplayMode: TaxDisplayMode;
}
/**
* Default company info
*/
export const createDefaultCompanyInfo = (): CompanyInfo => ({
name: '',
taxRate: 0.1, // 10%
taxDisplayMode: 'normal',
});

View file

@ -0,0 +1,39 @@
// Version: 2026-02-15
/**
* Customer data model
* Migrated from Flutter lib/models/customer_model.dart
*/
export interface Customer {
id: string;
displayName: string; // 略称
formalName: string; // 正式名称
title: string; // 敬称(様、御中、殿、貴社)
department?: string; // 部署名
address?: string; // 住所
tel?: string; // 電話番号
odooId?: number; // Odoo連携用ID
isSynced: boolean; // 同期済みフラグ
}
/**
* Get full display name with title
*/
export const getCustomerFullName = (customer: Customer): string => {
return `${customer.formalName} ${customer.title}`;
};
/**
* Create a new customer with default values
*/
export const createCustomer = (
id: string,
displayName: string = '',
formalName: string = ''
): Customer => ({
id,
displayName,
formalName,
title: '様',
isSynced: false,
});

View file

@ -0,0 +1,9 @@
// Version: 2026-02-15
/**
* Central export file for all models
*/
export * from './invoice';
export * from './customer';
export * from './product';
export * from './company';

View file

@ -0,0 +1,83 @@
// Version: 2026-02-15
/**
* Invoice data models
* Migrated from Flutter lib/models/invoice_models.dart
*/
export interface InvoiceItem {
description: string;
quantity: number;
unitPrice: number;
subtotal: number;
}
export type DocumentType =
| 'invoice' // 請求書
| 'quotation' // 見積書
| 'delivery' // 納品書
| 'receipt' // 領収書
| 'statement'; // 取引明細書
export type TaxDisplayMode = 'normal' | 'text_only' | 'hidden';
export interface Invoice {
id: string;
invoiceNumber: string;
date: Date;
customerId: string;
documentType: DocumentType;
items: InvoiceItem[];
subtotal: number;
tax: number;
totalAmount: number;
taxRate: number;
isDraft: boolean;
notes?: string;
subject?: string;
pdfPath?: string;
createdAt: Date;
updatedAt: Date;
signatureData?: string; // Base64 encoded signature image
}
/**
* Create a new invoice item with calculated subtotal
*/
export const createInvoiceItem = (
description: string = '',
quantity: number = 1,
unitPrice: number = 0
): InvoiceItem => ({
description,
quantity,
unitPrice,
subtotal: quantity * unitPrice,
});
/**
* Calculate invoice totals
*/
export const calculateInvoiceTotals = (
items: InvoiceItem[],
taxRate: number
): { subtotal: number; tax: number; totalAmount: number } => {
const subtotal = items.reduce((sum, item) => sum + item.subtotal, 0);
const tax = Math.floor(subtotal * taxRate);
const totalAmount = subtotal + tax;
return { subtotal, tax, totalAmount };
};
/**
* Get display name for document type
*/
export const getDocumentTypeName = (type: DocumentType): string => {
const names: Record<DocumentType, string> = {
invoice: '請求書',
quotation: '見積書',
delivery: '納品書',
receipt: '領収書',
statement: '取引明細書',
};
return names[type];
};

View file

@ -0,0 +1,36 @@
// Version: 2026-02-15
/**
* Product data model
* Migrated from Flutter lib/models/product_model.dart
*/
export interface Product {
id: string;
code: string; // 商品コード
name: string; // 商品名
unitPrice: number; // 単価
description?: string; // 説明
barcode?: string; // JANコード等
category?: string; // カテゴリー
isActive: boolean; // 有効フラグ
createdAt: Date;
updatedAt: Date;
}
/**
* Create a new product with default values
*/
export const createProduct = (
id: string,
code: string = '',
name: string = '',
unitPrice: number = 0
): Product => ({
id,
code,
name,
unitPrice,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
});

View file

@ -0,0 +1,200 @@
// Version: 2026-02-15
/**
* SQLite Database Service
* Migrated from Flutter lib/services/database_helper.dart
*/
import * as SQLite from 'expo-sqlite';
const DATABASE_NAME = 'gemi_invoice.db';
const DATABASE_VERSION = 1;
let db: SQLite.SQLiteDatabase | null = null;
/**
* Initialize database connection
*/
export const initDatabase = async (): Promise<SQLite.SQLiteDatabase> => {
if (db) return db;
db = await SQLite.openDatabaseAsync(DATABASE_NAME);
// Enable foreign keys
await db.execAsync('PRAGMA foreign_keys = ON;');
await createTables();
return db;
};
/**
* Get database instance
*/
export const getDatabase = async (): Promise<SQLite.SQLiteDatabase> => {
if (!db) {
return await initDatabase();
}
return db;
};
/**
* Create all database tables
*/
const createTables = async () => {
if (!db) throw new Error('Database not initialized');
// Company info table
await db.execAsync(`
CREATE TABLE IF NOT EXISTS company_info (
id INTEGER PRIMARY KEY CHECK (id = 1),
name TEXT NOT NULL,
postal_code TEXT,
address TEXT,
tel TEXT,
fax TEXT,
email TEXT,
representative_name TEXT,
tax_rate REAL NOT NULL DEFAULT 0.1,
tax_display_mode TEXT NOT NULL DEFAULT 'normal'
);
`);
// Customers table
await db.execAsync(`
CREATE TABLE IF NOT EXISTS customers (
id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
formal_name TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '様',
department TEXT,
address TEXT,
tel TEXT,
odoo_id INTEGER,
is_synced INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
`);
// Products table
await db.execAsync(`
CREATE TABLE IF NOT EXISTS products (
id TEXT PRIMARY KEY,
code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
unit_price REAL NOT NULL,
description TEXT,
barcode TEXT,
category TEXT,
is_active INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
`);
// Invoices table
await db.execAsync(`
CREATE TABLE IF NOT EXISTS invoices (
id TEXT PRIMARY KEY,
invoice_number TEXT NOT NULL UNIQUE,
date INTEGER NOT NULL,
customer_id TEXT NOT NULL,
document_type TEXT NOT NULL,
subtotal REAL NOT NULL,
tax REAL NOT NULL,
total_amount REAL NOT NULL,
tax_rate REAL NOT NULL,
is_draft INTEGER NOT NULL DEFAULT 1,
notes TEXT,
subject TEXT,
pdf_path TEXT,
signature_data TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (customer_id) REFERENCES customers(id)
);
`);
// Invoice items table
await db.execAsync(`
CREATE TABLE IF NOT EXISTS invoice_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
invoice_id TEXT NOT NULL,
description TEXT NOT NULL,
quantity REAL NOT NULL,
unit_price REAL NOT NULL,
subtotal REAL NOT NULL,
item_order INTEGER NOT NULL,
FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE CASCADE
);
`);
// GPS history table
await db.execAsync(`
CREATE TABLE IF NOT EXISTS gps_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_id TEXT NOT NULL,
latitude REAL NOT NULL,
longitude REAL NOT NULL,
recorded_at INTEGER NOT NULL,
FOREIGN KEY (customer_id) REFERENCES customers(id)
);
`);
// Activity log table
await db.execAsync(`
CREATE TABLE IF NOT EXISTS activity_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entity_type TEXT NOT NULL,
entity_id TEXT NOT NULL,
action TEXT NOT NULL,
description TEXT,
timestamp INTEGER NOT NULL,
metadata TEXT
);
`);
// Create indexes
await db.execAsync(`
CREATE INDEX IF NOT EXISTS idx_invoices_date ON invoices(date DESC);
CREATE INDEX IF NOT EXISTS idx_invoices_customer ON invoices(customer_id);
CREATE INDEX IF NOT EXISTS idx_invoice_items_invoice ON invoice_items(invoice_id);
CREATE INDEX IF NOT EXISTS idx_products_code ON products(code);
CREATE INDEX IF NOT EXISTS idx_gps_history_customer ON gps_history(customer_id);
CREATE INDEX IF NOT EXISTS idx_activity_logs_entity ON activity_logs(entity_type, entity_id);
`);
};
/**
* Close database connection
*/
export const closeDatabase = async () => {
if (db) {
await db.closeAsync();
db = null;
}
};
/**
* Execute a raw SQL query
*/
export const executeQuery = async (
sql: string,
params: any[] = []
): Promise<any[]> => {
const database = await getDatabase();
const result = await database.getAllAsync(sql, params);
return result;
};
/**
* Execute a raw SQL command (INSERT, UPDATE, DELETE)
*/
export const executeCommand = async (
sql: string,
params: any[] = []
): Promise<SQLite.SQLiteRunResult> => {
const database = await getDatabase();
const result = await database.runAsync(sql, params);
return result;
};

View file

@ -0,0 +1,399 @@
// Version: 2026-02-15
/**
* PDF Generation Service
* Migrated and optimized from Flutter lib/services/pdf_generator.dart
*
* Features:
* - A4 size professional invoice PDF
* - Japanese font support (IPA exGothic)
* - Table layout with items
* - Markdown parsing (bold, bullets)
* - SHA256 hash for audit trail
* - Tax display modes (normal, text_only, hidden)
*
* Note: This uses expo-print for React Native compatibility
* @react-pdf/renderer is primarily for web/server-side
*/
import * as FileSystem from 'expo-file-system';
import * as Print from 'expo-print';
import * as Crypto from 'expo-crypto';
import { format } from 'date-fns';
import { Invoice, getDocumentTypeName } from '../models';
import { getCompanyInfo } from './repositories/companyRepository';
/**
* Format number with thousand separators (Japanese style)
*/
const formatNumber = (num: number): string => {
return num.toLocaleString('ja-JP');
};
/**
* Generate SHA256 hash for invoice content (audit trail)
*/
export const generateContentHash = async (invoice: Invoice): Promise<string> => {
const content = JSON.stringify({
id: invoice.id,
invoiceNumber: invoice.invoiceNumber,
date: invoice.date.getTime(),
customerId: invoice.customerId,
items: invoice.items,
totalAmount: invoice.totalAmount,
});
const hash = await Crypto.digestStringAsync(
Crypto.CryptoDigestAlgorithm.SHA256,
content
);
return hash.substring(0, 8); // First 8 characters
};
/**
* Parse simple markdown for PDF (bullets and bold)
*/
const parseMarkdownToHTML = (text: string): string => {
const lines = text.split('\n');
return lines.map(line => {
let content = line;
let prefix = '';
let style = '';
// Bullet points
if (content.startsWith('* ') || content.startsWith('- ')) {
content = content.substring(2);
prefix = '• ';
} else if (content.startsWith(' ')) {
style = 'margin-left: 10px;';
}
// Bold text (**text**)
content = content.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
return `<div style="${style}">${prefix}${content}</div>`;
}).join('');
};
/**
* Generate invoice PDF using expo-print (HTML-based)
* This is the recommended approach for React Native/Expo
*/
export const generateInvoicePDF = async (
invoice: Invoice,
customerDisplayName: string
): Promise<string | null> => {
try {
// Get company info
const companyInfo = await getCompanyInfo();
// Generate content hash
const contentHash = await generateContentHash(invoice);
// Format date
const dateStr = format(invoice.date, 'yyyy年MM月dd日');
const documentTypeName = getDocumentTypeName(invoice.documentType);
// Determine greeting text
let greeting = '下記の通り、ご請求申し上げます。';
if (invoice.documentType === 'receipt') {
greeting = '上記の金額を正に領収いたしました。';
} else if (invoice.documentType === 'quotation') {
greeting = '下記の通り、お見積り申し上げます。';
}
// Total amount label
let totalLabel = '合計金額 (税込)';
if (invoice.documentType === 'receipt') {
totalLabel = companyInfo.taxDisplayMode === 'hidden' ? '領収金額' : '領収金額 (税込)';
} else {
totalLabel = companyInfo.taxDisplayMode === 'hidden' ? '合計金額' : '合計金額 (税込)';
}
// Generate HTML template
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
@page {
size: A4;
margin: 32px;
}
body {
font-family: 'Hiragino Kaku Gothic ProN', 'Hiragino Sans', 'Meiryo', sans-serif;
font-size: 10pt;
line-height: 1.4;
color: #000;
}
.header {
display: flex;
justify-content: space-between;
margin-bottom: 20px;
}
.title {
font-size: 28pt;
font-weight: bold;
}
.header-right {
text-align: right;
font-size: 10pt;
}
.address-section {
display: flex;
justify-content: space-between;
margin-bottom: 30px;
}
.customer-info {
flex: 1;
padding-right: 20px;
}
.customer-name {
font-size: 18pt;
border-bottom: 1px solid black;
padding-bottom: 4px;
margin-bottom: 10px;
}
.company-info {
flex: 1;
text-align: right;
font-size: 10pt;
}
.company-name {
font-size: 14pt;
font-weight: bold;
margin-bottom: 4px;
}
.total-box {
background-color: #e0e0e0;
padding: 8px;
margin-bottom: 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.total-label {
font-size: 16pt;
}
.total-value {
font-size: 20pt;
font-weight: bold;
}
table {
width: 100%;
border-collapse: collapse;
margin-bottom: 20px;
}
th, td {
border: 1px solid #ccc;
padding: 4px;
}
th {
background-color: #d0d0d0;
font-weight: bold;
text-align: left;
}
td.right {
text-align: right;
}
.summary {
width: 200px;
margin-left: auto;
}
.summary-row {
display: flex;
justify-content: space-between;
padding: 2px 0;
}
.summary-divider {
border-top: 1px solid black;
margin: 4px 0;
}
.summary-bold {
font-weight: bold;
font-size: 12pt;
}
.notes-section {
margin: 10px 0 20px 0;
}
.notes-title {
font-weight: bold;
margin-bottom: 4px;
}
.notes-box {
border: 1px solid #999;
padding: 8px;
}
.footer {
margin-top: 20px;
display: flex;
justify-content: space-between;
align-items: flex-end;
}
.hash-section {
font-size: 8pt;
color: #666;
}
.hash-value {
font-size: 10pt;
font-weight: bold;
color: #666;
}
.qr-placeholder {
width: 50px;
height: 50px;
border: 1px solid #ccc;
display: flex;
align-items: center;
justify-content: center;
font-size: 6pt;
}
</style>
</head>
<body>
<!-- Header -->
<div class="header">
<div class="title">${documentTypeName}</div>
<div class="header-right">
<div>番号: ${invoice.invoiceNumber}</div>
<div>発行日: ${dateStr}</div>
</div>
</div>
<!-- Customer and Company Info -->
<div class="address-section">
<div class="customer-info">
<div class="customer-name">${customerDisplayName}</div>
<div>${greeting}</div>
</div>
<div class="company-info">
<div class="company-name">${companyInfo.name}</div>
${companyInfo.postalCode ? `<div>〒${companyInfo.postalCode}</div>` : ''}
${companyInfo.address ? `<div>${companyInfo.address}</div>` : ''}
${companyInfo.tel ? `<div>TEL: ${companyInfo.tel}</div>` : ''}
${companyInfo.representativeName ? `<div style="font-size: 8pt; margin-top: 4px;">代表: ${companyInfo.representativeName}</div>` : ''}
</div>
</div>
<!-- Total Amount Box -->
<div class="total-box">
<span class="total-label">${totalLabel}</span>
<span class="total-value">${formatNumber(invoice.totalAmount)} -</span>
</div>
<!-- Items Table -->
<table>
<thead>
<tr>
<th style="width: 50%;"> / </th>
<th style="width: 15%; text-align: right;"></th>
<th style="width: 17.5%; text-align: right;"></th>
<th style="width: 17.5%; text-align: right;"></th>
</tr>
</thead>
<tbody>
${invoice.items.map(item => `
<tr>
<td>${parseMarkdownToHTML(item.description)}</td>
<td class="right">${item.quantity}</td>
<td class="right">${formatNumber(item.unitPrice)}</td>
<td class="right">${formatNumber(item.subtotal)}</td>
</tr>
`).join('')}
</tbody>
</table>
<!-- Summary -->
<div class="summary">
<div class="summary-row">
<span> ()</span>
<span>${formatNumber(invoice.subtotal)}</span>
</div>
${companyInfo.taxDisplayMode === 'normal' ? `
<div class="summary-row">
<span> (${Math.round(invoice.taxRate * 100)}%)</span>
<span>${formatNumber(invoice.tax)}</span>
</div>
` : ''}
${companyInfo.taxDisplayMode === 'text_only' ? `
<div class="summary-row">
<span></span>
<span></span>
</div>
` : ''}
<div class="summary-divider"></div>
<div class="summary-row summary-bold">
<span></span>
<span>${formatNumber(invoice.totalAmount)}</span>
</div>
</div>
<!-- Notes -->
${invoice.notes && invoice.notes.trim() !== '' ? `
<div class="notes-section">
<div class="notes-title">:</div>
<div class="notes-box">${invoice.notes}</div>
</div>
` : ''}
<!-- Footer with Hash -->
<div class="footer">
<div class="hash-section">
<div>Verification Hash (SHA256):</div>
<div class="hash-value">${contentHash}</div>
</div>
<div class="qr-placeholder">
QR: ${contentHash}
</div>
</div>
</body>
</html>
`;
// Generate PDF using expo-print
const { uri } = await Print.printToFileAsync({ html });
// Generate filename (same format as Flutter version)
const fileDateStr = format(invoice.date, 'yyyyMMdd');
const amountStr = formatNumber(invoice.totalAmount);
// Clean customer name (remove company suffixes)
const safeCustomerName = customerDisplayName
.replace(/株式会社|(株)|\(株\)|有限会社|(有)|\(有\)|合同会社|(同)|\(同\)/g, '')
.trim();
const subjectStr = invoice.subject ? `_${invoice.subject}` : '';
const fileName = `${fileDateStr}(${documentTypeName})${safeCustomerName}${subjectStr}_${amountStr}円_${contentHash}.pdf`;
// expo-print saves to cache directory by default
console.log('✅ PDF generated successfully:', fileName);
console.log('📄 Path:', uri);
console.log('🔒 Hash:', contentHash);
return uri;
} catch (error) {
console.error('❌ PDF Generation Error:', error);
return null;
}
};
/**
* Share generated PDF
*/
export const sharePDF = async (pdfPath: string): Promise<void> => {
try {
await Print.printAsync({ uri: pdfPath });
} catch (error) {
console.error('Share PDF Error:', error);
}
};

View file

@ -0,0 +1,55 @@
// Version: 2026-02-15
/**
* Company Repository
* Migrated from Flutter lib/services/company_repository.dart
*/
import { CompanyInfo, createDefaultCompanyInfo } from '../../models';
import { executeQuery, executeCommand } from '../database';
/**
* Get company information
*/
export const getCompanyInfo = async (): Promise<CompanyInfo> => {
const rows = await executeQuery('SELECT * FROM company_info WHERE id = 1');
if (rows.length === 0) {
return createDefaultCompanyInfo();
}
const row = rows[0];
return {
name: row.name,
postalCode: row.postal_code,
address: row.address,
tel: row.tel,
fax: row.fax,
email: row.email,
representativeName: row.representative_name,
taxRate: row.tax_rate,
taxDisplayMode: row.tax_display_mode,
};
};
/**
* Save company information
*/
export const saveCompanyInfo = async (info: CompanyInfo): Promise<void> => {
await executeCommand(
`INSERT OR REPLACE INTO company_info (
id, name, postal_code, address, tel, fax, email,
representative_name, tax_rate, tax_display_mode
) VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
info.name,
info.postalCode || null,
info.address || null,
info.tel || null,
info.fax || null,
info.email || null,
info.representativeName || null,
info.taxRate,
info.taxDisplayMode,
]
);
};

View file

@ -0,0 +1,87 @@
// Version: 2026-02-15
/**
* Customer Repository
* Migrated from Flutter lib/services/customer_repository.dart
*/
import { Customer } from '../../models';
import { executeQuery, executeCommand } from '../database';
/**
* Get all customers
*/
export const getAllCustomers = async (): Promise<Customer[]> => {
const rows = await executeQuery(
`SELECT * FROM customers ORDER BY display_name ASC`
);
return rows.map(row => ({
id: row.id,
displayName: row.display_name,
formalName: row.formal_name,
title: row.title,
department: row.department,
address: row.address,
tel: row.tel,
odooId: row.odoo_id,
isSynced: row.is_synced === 1,
}));
};
/**
* Save a customer
*/
export const saveCustomer = async (customer: Customer): Promise<void> => {
const now = Date.now();
await executeCommand(
`INSERT OR REPLACE INTO customers (
id, display_name, formal_name, title, department, address, tel,
odoo_id, is_synced, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
customer.id,
customer.displayName,
customer.formalName,
customer.title,
customer.department || null,
customer.address || null,
customer.tel || null,
customer.odooId || null,
customer.isSynced ? 1 : 0,
now,
now,
]
);
};
/**
* Delete a customer
*/
export const deleteCustomer = async (id: string): Promise<void> => {
await executeCommand('DELETE FROM customers WHERE id = ?', [id]);
};
/**
* Search customers by name
*/
export const searchCustomers = async (query: string): Promise<Customer[]> => {
const rows = await executeQuery(
`SELECT * FROM customers
WHERE display_name LIKE ? OR formal_name LIKE ?
ORDER BY display_name ASC`,
[`%${query}%`, `%${query}%`]
);
return rows.map(row => ({
id: row.id,
displayName: row.display_name,
formalName: row.formal_name,
title: row.title,
department: row.department,
address: row.address,
tel: row.tel,
odooId: row.odoo_id,
isSynced: row.is_synced === 1,
}));
};

View file

@ -0,0 +1,171 @@
// Version: 2026-02-15
/**
* Invoice Repository
* Migrated from Flutter lib/services/invoice_repository.dart
*/
import { v4 as uuidv4 } from 'uuid';
import { Invoice, InvoiceItem, Customer } from '../../models';
import { executeQuery, executeCommand } from '../database';
/**
* Save an invoice to the database
*/
export const saveInvoice = async (invoice: Invoice): Promise<void> => {
// Insert/Update invoice
await executeCommand(
`INSERT OR REPLACE INTO invoices (
id, invoice_number, date, customer_id, document_type,
subtotal, tax, total_amount, tax_rate, is_draft,
notes, subject, pdf_path, signature_data, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
invoice.id,
invoice.invoiceNumber,
invoice.date.getTime(),
invoice.customerId,
invoice.documentType,
invoice.subtotal,
invoice.tax,
invoice.totalAmount,
invoice.taxRate,
invoice.isDraft ? 1 : 0,
invoice.notes || null,
invoice.subject || null,
invoice.pdfPath || null,
invoice.signatureData || null,
invoice.createdAt.getTime(),
invoice.updatedAt.getTime(),
]
);
// Delete existing items
await executeCommand('DELETE FROM invoice_items WHERE invoice_id = ?', [invoice.id]);
// Insert items
for (let i = 0; i < invoice.items.length; i++) {
const item = invoice.items[i];
await executeCommand(
`INSERT INTO invoice_items (invoice_id, description, quantity, unit_price, subtotal, item_order)
VALUES (?, ?, ?, ?, ?, ?)`,
[invoice.id, item.description, item.quantity, item.unitPrice, item.subtotal, i]
);
}
};
/**
* Get all invoices
*/
export const getAllInvoices = async (customers: Customer[]): Promise<Invoice[]> => {
const rows = await executeQuery(
`SELECT * FROM invoices ORDER BY date DESC`
);
const customerMap = new Map(customers.map(c => [c.id, c]));
const invoices: Invoice[] = [];
for (const row of rows) {
const items = await getInvoiceItems(row.id);
invoices.push({
id: row.id,
invoiceNumber: row.invoice_number,
date: new Date(row.date),
customerId: row.customer_id,
documentType: row.document_type,
items,
subtotal: row.subtotal,
tax: row.tax,
totalAmount: row.total_amount,
taxRate: row.tax_rate,
isDraft: row.is_draft === 1,
notes: row.notes,
subject: row.subject,
pdfPath: row.pdf_path,
signatureData: row.signature_data,
createdAt: new Date(row.created_at),
updatedAt: new Date(row.updated_at),
});
}
return invoices;
};
/**
* Get invoice items by invoice ID
*/
const getInvoiceItems = async (invoiceId: string): Promise<InvoiceItem[]> => {
const rows = await executeQuery(
`SELECT * FROM invoice_items WHERE invoice_id = ? ORDER BY item_order`,
[invoiceId]
);
return rows.map(row => ({
description: row.description,
quantity: row.quantity,
unitPrice: row.unit_price,
subtotal: row.subtotal,
}));
};
/**
* Get invoice by ID
*/
export const getInvoiceById = async (id: string): Promise<Invoice | null> => {
const rows = await executeQuery('SELECT * FROM invoices WHERE id = ?', [id]);
if (rows.length === 0) return null;
const row = rows[0];
const items = await getInvoiceItems(id);
return {
id: row.id,
invoiceNumber: row.invoice_number,
date: new Date(row.date),
customerId: row.customer_id,
documentType: row.document_type,
items,
subtotal: row.subtotal,
tax: row.tax,
totalAmount: row.total_amount,
taxRate: row.tax_rate,
isDraft: row.is_draft === 1,
notes: row.notes,
subject: row.subject,
pdfPath: row.pdf_path,
signatureData: row.signature_data,
createdAt: new Date(row.created_at),
updatedAt: new Date(row.updated_at),
};
};
/**
* Delete an invoice
*/
export const deleteInvoice = async (id: string): Promise<void> => {
await executeCommand('DELETE FROM invoices WHERE id = ?', [id]);
};
/**
* Generate next invoice number
*/
export const generateInvoiceNumber = async (documentType: string): Promise<string> => {
const prefix = documentType.substring(0, 3).toUpperCase();
const rows = await executeQuery(
`SELECT invoice_number FROM invoices WHERE invoice_number LIKE ? ORDER BY invoice_number DESC LIMIT 1`,
[`${prefix}%`]
);
if (rows.length === 0) {
return `${prefix}-0001`;
}
const lastNumber = rows[0].invoice_number;
const match = lastNumber.match(/-(\d+)$/);
if (!match) {
return `${prefix}-0001`;
}
const nextNumber = parseInt(match[1], 10) + 1;
return `${prefix}-${nextNumber.toString().padStart(4, '0')}`;
};

View file

@ -0,0 +1,88 @@
// Version: 2026-02-15
/**
* Product Repository
* Migrated from Flutter lib/services/product_repository.dart
*/
import { Product } from '../../models';
import { executeQuery, executeCommand } from '../database';
/**
* Get all products
*/
export const getAllProducts = async (): Promise<Product[]> => {
const rows = await executeQuery(
`SELECT * FROM products WHERE is_active = 1 ORDER BY code ASC`
);
return rows.map(row => ({
id: row.id,
code: row.code,
name: row.name,
unitPrice: row.unit_price,
description: row.description,
barcode: row.barcode,
category: row.category,
isActive: row.is_active === 1,
createdAt: new Date(row.created_at),
updatedAt: new Date(row.updated_at),
}));
};
/**
* Save a product
*/
export const saveProduct = async (product: Product): Promise<void> => {
const now = Date.now();
await executeCommand(
`INSERT OR REPLACE INTO products (
id, code, name, unit_price, description, barcode, category,
is_active, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
product.id,
product.code,
product.name,
product.unitPrice,
product.description || null,
product.barcode || null,
product.category || null,
product.isActive ? 1 : 0,
now,
now,
]
);
};
/**
* Delete a product
*/
export const deleteProduct = async (id: string): Promise<void> => {
await executeCommand('UPDATE products SET is_active = 0 WHERE id = ?', [id]);
};
/**
* Search products
*/
export const searchProducts = async (query: string): Promise<Product[]> => {
const rows = await executeQuery(
`SELECT * FROM products
WHERE is_active = 1 AND (code LIKE ? OR name LIKE ?)
ORDER BY code ASC`,
[`%${query}%`, `%${query}%`]
);
return rows.map(row => ({
id: row.id,
code: row.code,
name: row.name,
unitPrice: row.unit_price,
description: row.description,
barcode: row.barcode,
category: row.category,
isActive: row.is_active === 1,
createdAt: new Date(row.created_at),
updatedAt: new Date(row.updated_at),
}));
};

View file

@ -0,0 +1,6 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true
}
}

View file

@ -73,6 +73,23 @@
+ 商品マスターにはグルーピング機能を追加する(商品を選択すると芋蔓式にインデントした商品が引用される) + 商品マスターにはグルーピング機能を追加する(商品を選択すると芋蔓式にインデントした商品が引用される)
+ 顧客マスターにはメールアドレスが必要PDFを送信するから + 顧客マスターにはメールアドレスが必要PDFを送信するから
+ ファイル名には株式会社や有限会社は除去して社名だけを引用する + ファイル名には株式会社や有限会社は除去して社名だけを引用する
+ 伝票を新規発行する時は顧客名から引用できる伝票を表示し選択して引用する機能を実装する + 伝票を新規発行する時は顧客名から引用できる伝票を表示し選択して引用する機能を実装する(領収証は請求書を参照し場合によっては複数を合算して領収します)
+ 左上の戻る矢印は伝票マスター一覧ではこれ以上戻れないので表示しない
+ 伝票編集や表示中にコピーしてから編集ボタンを作る
+ 顧客マスターに電話帳を参照せずとも新規登録可能にするその場合逆に電話帳へコピーする機能があれば尚良い
+ 伝票マスター一覧から選択した後の画面でもPDFプレビューと共有は必要
+ 伝票マスター一覧を表示した時にリロードしないと最新の伝票が表示しない場合が有る
+ 備考非課税は不要
+ 明細編集で項目を上下ポジション変更可能にする
+ 実験的オプション表示は廃止
+ 保存ボタンの後待たされるので保存エフェクトが必要
+ 右上のカレンダーアイコンが謎
+ 明細の編集で商品マスターの参照が出来ない
+ 案件マスターと案件マスター保守画面を実装する
+ 上級者向けにmarkdownエディタを実装する
+ 商品マスターのグルーピング?クラスター?マクロ?専用管理画面を実装する
+ 伝票マスターの検索で商品検索や備考を検索したりする深い検索モードの実装
+ 明細の編集を途中で中断した場合の処理(兎に角下書きに保存しちゃえ)
+ 下書きは別扱いで表示する時には特別色を使う
+ 処理が重い時は作業中のエフェクトが必要だと思う