reactにコンバートした sonnetで
This commit is contained in:
parent
b11ae890ce
commit
7aeec4225a
31 changed files with 11882 additions and 101 deletions
10
.antigravityignore
Normal file
10
.antigravityignore
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
# AIに読ませても枠を溶かすだけのゴミたち
|
||||||
|
.dart_tool/
|
||||||
|
build/
|
||||||
|
ios/
|
||||||
|
android/
|
||||||
|
web/
|
||||||
|
*.lock
|
||||||
|
*.svg
|
||||||
|
assets/
|
||||||
|
.env
|
||||||
|
|
@ -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), // アイコン変更
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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,39 +202,57 @@ 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: [
|
children: [
|
||||||
Expanded(
|
Column(
|
||||||
child: SingleChildScrollView(
|
children: [
|
||||||
padding: const EdgeInsets.all(16.0),
|
Expanded(
|
||||||
child: Column(
|
child: SingleChildScrollView(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
padding: const EdgeInsets.all(16.0),
|
||||||
children: [
|
child: Column(
|
||||||
_buildDraftToggle(), // 追加
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
const SizedBox(height: 16),
|
children: [
|
||||||
_buildDocumentTypeSection(),
|
_buildDraftToggle(),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_buildDateSection(),
|
_buildDocumentTypeSection(),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_buildCustomerSection(),
|
_buildDateSection(),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_buildSubjectSection(textColor), // 追加
|
_buildCustomerSection(),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 16),
|
||||||
_buildItemsSection(fmt),
|
_buildSubjectSection(textColor),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
_buildExperimentalSection(),
|
_buildItemsSection(fmt),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
_buildSummarySection(fmt),
|
_buildTaxSettings(),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
_buildSignatureSection(),
|
_buildSummarySection(fmt),
|
||||||
],
|
const SizedBox(height: 20),
|
||||||
|
_buildSignatureSection(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
_buildBottomActionBar(),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -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,42 +474,37 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildExperimentalSection() {
|
Widget _buildTaxSettings() {
|
||||||
return Container(
|
return Column(
|
||||||
padding: const EdgeInsets.all(12),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
decoration: BoxDecoration(color: Colors.orange.shade50, borderRadius: BorderRadius.circular(12)),
|
children: [
|
||||||
child: Column(
|
Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
children: [
|
||||||
children: [
|
if (_includeTax) ...[
|
||||||
const Text("実験的オプション", style: TextStyle(fontWeight: FontWeight.bold, color: Colors.orange)),
|
const Text("消費税率: ", style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(width: 8),
|
||||||
Row(
|
ChoiceChip(
|
||||||
children: [
|
label: const Text("10%"),
|
||||||
if (_includeTax) ...[
|
selected: _taxRate == 0.10,
|
||||||
const Text("消費税: "),
|
onSelected: (val) => setState(() => _taxRate = 0.10),
|
||||||
ChoiceChip(
|
|
||||||
label: const Text("10%"),
|
|
||||||
selected: _taxRate == 0.10,
|
|
||||||
onSelected: (val) => setState(() => _taxRate = 0.10),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
ChoiceChip(
|
|
||||||
label: const Text("8%"),
|
|
||||||
selected: _taxRate == 0.08,
|
|
||||||
onSelected: (val) => setState(() => _taxRate = 0.08),
|
|
||||||
),
|
|
||||||
] else
|
|
||||||
const Text("(税別設定のため設定なし)", style: TextStyle(color: Colors.grey)),
|
|
||||||
const Spacer(),
|
|
||||||
Switch(
|
|
||||||
value: _includeTax,
|
|
||||||
onChanged: (val) => setState(() => _includeTax = val),
|
|
||||||
),
|
),
|
||||||
Text(_includeTax ? "税込表示" : "非課税"),
|
const SizedBox(width: 8),
|
||||||
],
|
ChoiceChip(
|
||||||
),
|
label: const Text("8%"),
|
||||||
],
|
selected: _taxRate == 0.08,
|
||||||
),
|
onSelected: (val) => setState(() => _taxRate = 0.08),
|
||||||
|
),
|
||||||
|
] else
|
||||||
|
const Text("消費税設定: 非課税", style: TextStyle(fontWeight: FontWeight.bold, color: Colors.grey)),
|
||||||
|
const Spacer(),
|
||||||
|
Switch(
|
||||||
|
value: _includeTax,
|
||||||
|
onChanged: (val) => setState(() => _includeTax = val),
|
||||||
|
),
|
||||||
|
Text(_includeTax ? "税込計算" : "非課税"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
41
react_native_app/.gitignore
vendored
Normal file
41
react_native_app/.gitignore
vendored
Normal 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
133
react_native_app/App.tsx
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
74
react_native_app/README.md
Normal file
74
react_native_app/README.md
Normal 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
34
react_native_app/app.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
react_native_app/assets/adaptive-icon.png
Normal file
BIN
react_native_app/assets/adaptive-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
react_native_app/assets/favicon.png
Normal file
BIN
react_native_app/assets/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
react_native_app/assets/icon.png
Normal file
BIN
react_native_app/assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
react_native_app/assets/splash-icon.png
Normal file
BIN
react_native_app/assets/splash-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
8
react_native_app/index.ts
Normal file
8
react_native_app/index.ts
Normal 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
9805
react_native_app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
44
react_native_app/package.json
Normal file
44
react_native_app/package.json
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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%',
|
||||||
|
},
|
||||||
|
});
|
||||||
188
react_native_app/src/components/InvoiceForm/ItemsSection.tsx
Normal file
188
react_native_app/src/components/InvoiceForm/ItemsSection.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
28
react_native_app/src/models/company.ts
Normal file
28
react_native_app/src/models/company.ts
Normal 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',
|
||||||
|
});
|
||||||
39
react_native_app/src/models/customer.ts
Normal file
39
react_native_app/src/models/customer.ts
Normal 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,
|
||||||
|
});
|
||||||
9
react_native_app/src/models/index.ts
Normal file
9
react_native_app/src/models/index.ts
Normal 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';
|
||||||
83
react_native_app/src/models/invoice.ts
Normal file
83
react_native_app/src/models/invoice.ts
Normal 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];
|
||||||
|
};
|
||||||
36
react_native_app/src/models/product.ts
Normal file
36
react_native_app/src/models/product.ts
Normal 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(),
|
||||||
|
});
|
||||||
200
react_native_app/src/services/database.ts
Normal file
200
react_native_app/src/services/database.ts
Normal 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;
|
||||||
|
};
|
||||||
399
react_native_app/src/services/pdfService.ts
Normal file
399
react_native_app/src/services/pdfService.ts
Normal 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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,
|
||||||
|
}));
|
||||||
|
};
|
||||||
171
react_native_app/src/services/repositories/invoiceRepository.ts
Normal file
171
react_native_app/src/services/repositories/invoiceRepository.ts
Normal 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')}`;
|
||||||
|
};
|
||||||
|
|
@ -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),
|
||||||
|
}));
|
||||||
|
};
|
||||||
6
react_native_app/tsconfig.json
Normal file
6
react_native_app/tsconfig.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"extends": "expo/tsconfig.base",
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true
|
||||||
|
}
|
||||||
|
}
|
||||||
21
目標.md
21
目標.md
|
|
@ -73,6 +73,23 @@
|
||||||
+ 商品マスターにはグルーピング機能を追加する(商品を選択すると芋蔓式にインデントした商品が引用される)
|
+ 商品マスターにはグルーピング機能を追加する(商品を選択すると芋蔓式にインデントした商品が引用される)
|
||||||
+ 顧客マスターにはメールアドレスが必要(PDFを送信するから)
|
+ 顧客マスターにはメールアドレスが必要(PDFを送信するから)
|
||||||
+ ファイル名には株式会社や有限会社は除去して社名だけを引用する
|
+ ファイル名には株式会社や有限会社は除去して社名だけを引用する
|
||||||
+ 伝票を新規発行する時は顧客名から引用できる伝票を表示し選択して引用する機能を実装する
|
+ 伝票を新規発行する時は顧客名から引用できる伝票を表示し選択して引用する機能を実装する(領収証は請求書を参照し場合によっては複数を合算して領収します)
|
||||||
|
+ 左上の戻る矢印は伝票マスター一覧ではこれ以上戻れないので表示しない
|
||||||
|
+ 伝票編集や表示中にコピーしてから編集ボタンを作る
|
||||||
|
+ 顧客マスターに電話帳を参照せずとも新規登録可能にするその場合逆に電話帳へコピーする機能があれば尚良い
|
||||||
|
+ 伝票マスター一覧から選択した後の画面でもPDFプレビューと共有は必要
|
||||||
|
+ 伝票マスター一覧を表示した時にリロードしないと最新の伝票が表示しない場合が有る
|
||||||
|
+ 備考非課税は不要
|
||||||
|
+ 明細編集で項目を上下ポジション変更可能にする
|
||||||
|
+ 実験的オプション表示は廃止
|
||||||
|
+ 保存ボタンの後待たされるので保存エフェクトが必要
|
||||||
|
+ 右上のカレンダーアイコンが謎
|
||||||
|
+ 明細の編集で商品マスターの参照が出来ない
|
||||||
|
+ 案件マスターと案件マスター保守画面を実装する
|
||||||
|
+ 上級者向けにmarkdownエディタを実装する
|
||||||
|
+ 商品マスターのグルーピング?クラスター?マクロ?専用管理画面を実装する
|
||||||
|
+ 伝票マスターの検索で商品検索や備考を検索したりする深い検索モードの実装
|
||||||
|
+ 明細の編集を途中で中断した場合の処理(兎に角下書きに保存しちゃえ)
|
||||||
|
+ 下書きは別扱いで表示する時には特別色を使う
|
||||||
|
+ 処理が重い時は作業中のエフェクトが必要だと思う
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue