feat: add isPrivate field to Product model and implement private product creation and retrieval
This commit is contained in:
@@ -4,6 +4,8 @@ class AuthApiPaths {
|
||||
|
||||
class ProductApiPaths {
|
||||
static const list = '/products';
|
||||
static const mine = '/products/mine';
|
||||
static const createPrivate = '/products/private';
|
||||
static const pending = '/products/pending';
|
||||
static const aiCategorizeBulk = '/products/ai-categorize-bulk';
|
||||
static const deleted = '/products/deleted';
|
||||
|
||||
@@ -6,6 +6,9 @@ import '../../features/admin/domain/admin_category_node.dart';
|
||||
///
|
||||
/// Returnerar det valda produkt-id:t, eller null om användaren avbryter.
|
||||
///
|
||||
/// [onCreate] — valfri callback för att skapa ny produkt; anropas med produktnamnet
|
||||
/// och returnerar `ProductOption` för den skapade produkten.
|
||||
///
|
||||
/// Anropas via [CategoryThenProductPicker.show].
|
||||
class CategoryThenProductPicker {
|
||||
CategoryThenProductPicker._();
|
||||
@@ -35,12 +38,16 @@ class CategoryThenProductPicker {
|
||||
///
|
||||
/// [preselectedCategoryId] — om satt scrollas trädet till den noden och den
|
||||
/// markeras visuellt. Användaren kan fortfarande välja en annan kategori.
|
||||
///
|
||||
/// [onCreate] — valfri callback; om satt visas "Skapa ny" i produktpickern.
|
||||
/// Anropas med produktnamnet och ska returnera den nya `ProductOption`.
|
||||
static Future<int?> show(
|
||||
BuildContext context, {
|
||||
required List<AdminCategoryNode> categoryTree,
|
||||
required List<ProductOption> products,
|
||||
int? currentProductId,
|
||||
int? preselectedCategoryId,
|
||||
Future<ProductOption?> Function(String name, int categoryId)? onCreate,
|
||||
}) async {
|
||||
// Steg 1 — välj kategori
|
||||
final selectedCategory = await showModalBottomSheet<AdminCategoryNode>(
|
||||
@@ -64,6 +71,11 @@ class CategoryThenProductPicker {
|
||||
.toList();
|
||||
final useList = filtered.isNotEmpty ? filtered : products;
|
||||
|
||||
// Bygg eventuell onCreate-callback med categoryId inbunden
|
||||
final onCreateBound = onCreate == null
|
||||
? null
|
||||
: (String name) => onCreate(name, selectedCategory.id);
|
||||
|
||||
// Steg 2 — välj produkt
|
||||
if (!context.mounted) return null;
|
||||
return ProductPickerField.showSheet(
|
||||
@@ -72,6 +84,7 @@ class CategoryThenProductPicker {
|
||||
value: currentProductId,
|
||||
label: 'Produkt i "${selectedCategory.name}"',
|
||||
categoryFilter: null, // redan förfiltrerat
|
||||
onCreate: onCreateBound,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,7 +112,8 @@ class ProductPickerField extends StatelessWidget {
|
||||
/// Returnerar valt produkt-id, null (ingen ändring), eller [_clearSelectionToken] (rensa).
|
||||
///
|
||||
/// [categoryFilter] — om satt visas bara produkter vars categoryId finns i listan.
|
||||
/// Används med AI-kategorisuggestion för att förifiltrera på rätt kategorigren.
|
||||
/// [onCreate] — valfri callback för att skapa en ny produkt; anropas med produktnamnet
|
||||
/// och returnerar den nya `ProductOption` om skapandet lyckades.
|
||||
static Future<int?> showSheet(
|
||||
BuildContext context, {
|
||||
required List<ProductOption> products,
|
||||
@@ -120,6 +121,7 @@ class ProductPickerField extends StatelessWidget {
|
||||
String label = 'Produkt',
|
||||
String? initialQuery,
|
||||
Set<int>? categoryFilter,
|
||||
Future<ProductOption?> Function(String name)? onCreate,
|
||||
}) async {
|
||||
// Filtrera på kategori om angiven, men visa alla om filtret ger nollresultat
|
||||
final baseList = categoryFilter != null && categoryFilter.isNotEmpty
|
||||
@@ -134,17 +136,54 @@ class ProductPickerField extends StatelessWidget {
|
||||
builder: (sheetContext) {
|
||||
final controller = TextEditingController(text: initialQuery ?? '');
|
||||
var query = initialQuery ?? '';
|
||||
// Mutable lokal kopia — nya produkter kan läggas till
|
||||
var displayList = useList.toList();
|
||||
|
||||
return StatefulBuilder(
|
||||
builder: (ctx, setModalState) {
|
||||
final normalizedQuery = query.trim().toLowerCase();
|
||||
final filtered = normalizedQuery.isEmpty
|
||||
? useList
|
||||
: useList
|
||||
? displayList
|
||||
: displayList
|
||||
.where((p) => p.name.toLowerCase().contains(normalizedQuery))
|
||||
.toList();
|
||||
|
||||
final isFiltered = useList.length < products.length;
|
||||
final isFiltered = displayList.length < products.length;
|
||||
|
||||
// "Skapa ny produkt"-dialog
|
||||
Future<void> openCreateDialog() async {
|
||||
final nameCtrl = TextEditingController(text: query.trim());
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: ctx,
|
||||
builder: (dCtx) => AlertDialog(
|
||||
title: const Text('Ny produkt'),
|
||||
content: TextField(
|
||||
controller: nameCtrl,
|
||||
autofocus: true,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Produktnamn',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dCtx, false),
|
||||
child: const Text('Avbryt'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(dCtx, true),
|
||||
child: const Text('Skapa'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !ctx.mounted) return;
|
||||
final newProduct = await onCreate!(nameCtrl.text);
|
||||
if (newProduct == null || !ctx.mounted) return;
|
||||
setModalState(() => displayList = [...displayList, newProduct]);
|
||||
if (ctx.mounted) Navigator.pop(ctx, newProduct.id);
|
||||
}
|
||||
|
||||
return SizedBox(
|
||||
height: MediaQuery.of(ctx).size.height * 0.85,
|
||||
@@ -161,12 +200,18 @@ class ProductPickerField extends StatelessWidget {
|
||||
Text(label, style: Theme.of(ctx).textTheme.titleMedium),
|
||||
if (isFiltered)
|
||||
Text(
|
||||
'Visar ${useList.length} produkter i föreslagen kategori',
|
||||
'Visar ${displayList.length} produkter i föreslagen kategori',
|
||||
style: Theme.of(ctx).textTheme.labelSmall?.copyWith(color: Colors.green.shade700),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (onCreate != null)
|
||||
TextButton.icon(
|
||||
onPressed: openCreateDialog,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Skapa ny'),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () => Navigator.pop(ctx, _clearSelectionToken),
|
||||
icon: const Icon(Icons.clear),
|
||||
|
||||
@@ -142,6 +142,23 @@ class AdminRepository {
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Skapar en ny aktiv produkt (kräver admin). Returnerar `{id, name, categoryId?}`.
|
||||
Future<Map<String, dynamic>> createProduct(String name, {int? categoryId}) async {
|
||||
final token = await _token();
|
||||
final data = await guardedApiCall(
|
||||
_ref,
|
||||
() => _apiClient.postJson(
|
||||
ProductApiPaths.list,
|
||||
body: {
|
||||
'name': name.trim(),
|
||||
if (categoryId != null) 'categoryId': categoryId,
|
||||
},
|
||||
token: token,
|
||||
),
|
||||
);
|
||||
return data as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
Future<List<AdminProduct>> listDeletedProducts() async {
|
||||
final token = await _token();
|
||||
final data = await guardedApiCall(
|
||||
|
||||
@@ -44,12 +44,14 @@ class _EditDialog extends StatefulWidget {
|
||||
final _ItemEdit current;
|
||||
final List<ProductOption> products;
|
||||
final List<AdminCategoryNode> categoryTree;
|
||||
final Future<ProductOption?> Function(String name, int categoryId)? onCreate;
|
||||
|
||||
const _EditDialog({
|
||||
required this.item,
|
||||
required this.current,
|
||||
required this.products,
|
||||
required this.categoryTree,
|
||||
this.onCreate,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -62,6 +64,8 @@ class _EditDialogState extends State<_EditDialog> {
|
||||
int? _productId;
|
||||
String? _productName;
|
||||
_Destination _destination = _Destination.inventory;
|
||||
// Lokal lista — utökas om nya produkter skapas under dialogen
|
||||
late List<ProductOption> _localProducts;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -69,6 +73,7 @@ class _EditDialogState extends State<_EditDialog> {
|
||||
_productId = widget.current.productId;
|
||||
_productName = widget.current.productName;
|
||||
_destination = widget.current.destination;
|
||||
_localProducts = List.of(widget.products);
|
||||
_quantityCtrl = TextEditingController(
|
||||
text: (widget.current.quantity ?? widget.item.quantity)?.toString() ?? '',
|
||||
);
|
||||
@@ -95,17 +100,30 @@ class _EditDialogState extends State<_EditDialog> {
|
||||
|
||||
// Hjälpfunktion: välj produkt via tvåstegs-picker (kategori → produkt)
|
||||
Future<void> openCategoryPicker({int? preselectedCategoryId}) async {
|
||||
// onCreate-wrapper: lägg även till den nya produkten i _localProducts
|
||||
Future<ProductOption?> Function(String, int)? onCreateWrapped;
|
||||
if (widget.onCreate != null) {
|
||||
onCreateWrapped = (name, categoryId) async {
|
||||
final newProduct = await widget.onCreate!(name, categoryId);
|
||||
if (newProduct != null && mounted) {
|
||||
setState(() => _localProducts = [..._localProducts, newProduct]);
|
||||
}
|
||||
return newProduct;
|
||||
};
|
||||
}
|
||||
|
||||
final id = await CategoryThenProductPicker.show(
|
||||
context,
|
||||
categoryTree: widget.categoryTree,
|
||||
products: widget.products,
|
||||
products: _localProducts,
|
||||
currentProductId: _productId,
|
||||
preselectedCategoryId: preselectedCategoryId,
|
||||
onCreate: onCreateWrapped,
|
||||
);
|
||||
if (id != null && mounted) {
|
||||
setState(() {
|
||||
_productId = id;
|
||||
_productName = widget.products
|
||||
_productName = _localProducts
|
||||
.cast<ProductOption?>()
|
||||
.firstWhere((p) => p?.id == id, orElse: () => null)
|
||||
?.name;
|
||||
@@ -178,7 +196,7 @@ class _EditDialogState extends State<_EditDialog> {
|
||||
children: [
|
||||
Expanded(
|
||||
child: ProductPickerField(
|
||||
products: widget.products,
|
||||
products: _localProducts,
|
||||
value: _productId,
|
||||
label: 'Produkt',
|
||||
onChanged: (id) {
|
||||
@@ -186,7 +204,7 @@ class _EditDialogState extends State<_EditDialog> {
|
||||
_productId = id;
|
||||
_productName = id == null
|
||||
? null
|
||||
: widget.products
|
||||
: _localProducts
|
||||
.cast<ProductOption?>()
|
||||
.firstWhere((p) => p?.id == id, orElse: () => null)
|
||||
?.name;
|
||||
@@ -303,17 +321,28 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
final adminRepo = ref.read(adminRepositoryProvider);
|
||||
final results = await Future.wait([
|
||||
api.getJson(ProductApiPaths.list, token: token),
|
||||
api.getJson(ProductApiPaths.mine, token: token),
|
||||
adminRepo.listCategoryTree(),
|
||||
]);
|
||||
final data = results[0];
|
||||
final list = data is List ? data : ((data as Map<String, dynamic>?)?['items'] as List? ?? []);
|
||||
final globalData = results[0];
|
||||
final mineData = results[1];
|
||||
final globalList = globalData is List
|
||||
? globalData
|
||||
: ((globalData as Map<String, dynamic>?)?['items'] as List? ?? []);
|
||||
final mineList = mineData is List
|
||||
? mineData
|
||||
: ((mineData as Map<String, dynamic>?)?['items'] as List? ?? []);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_products = list
|
||||
.cast<Map<String, dynamic>>()
|
||||
.map((e) => (id: e['id'] as int, name: e['name'] as String, categoryId: (e['categoryId'] as num?)?.toInt()))
|
||||
.toList();
|
||||
_categoryTree = results[1] as List<AdminCategoryNode>;
|
||||
_products = [
|
||||
...globalList
|
||||
.cast<Map<String, dynamic>>()
|
||||
.map((e) => (id: e['id'] as int, name: (e['canonicalName'] ?? e['name']) as String, categoryId: (e['categoryId'] as num?)?.toInt())),
|
||||
...mineList
|
||||
.cast<Map<String, dynamic>>()
|
||||
.map((e) => (id: e['id'] as int, name: (e['canonicalName'] ?? e['name']) as String, categoryId: (e['categoryId'] as num?)?.toInt())),
|
||||
];
|
||||
_categoryTree = results[2] as List<AdminCategoryNode>;
|
||||
_loadingProducts = false;
|
||||
});
|
||||
}
|
||||
@@ -413,7 +442,35 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
|
||||
final result = await showDialog<_ItemEdit>(
|
||||
context: context,
|
||||
builder: (_) => _EditDialog(item: item, current: current, products: _products, categoryTree: _categoryTree),
|
||||
builder: (_) => _EditDialog(
|
||||
item: item,
|
||||
current: current,
|
||||
products: _products,
|
||||
categoryTree: _categoryTree,
|
||||
onCreate: (name, categoryId) async {
|
||||
try {
|
||||
final token = await ref.read(authStateProvider.future);
|
||||
final api = ref.read(apiClientProvider);
|
||||
final data = await api.postJson(
|
||||
ProductApiPaths.createPrivate,
|
||||
body: {
|
||||
'name': name.trim(),
|
||||
'categoryId': categoryId,
|
||||
},
|
||||
token: token,
|
||||
) as Map<String, dynamic>;
|
||||
final newProduct = (
|
||||
id: data['id'] as int,
|
||||
name: (data['canonicalName'] ?? data['name']) as String,
|
||||
categoryId: categoryId,
|
||||
);
|
||||
if (mounted) setState(() => _products = [..._products, newProduct]);
|
||||
return newProduct;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
if (result != null && mounted) {
|
||||
setState(() {
|
||||
|
||||
Reference in New Issue
Block a user