feat: add isPrivate field to Product model and implement private product creation and retrieval
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
-- Privata produkter: skapade av användare, synliga bara för ägaren
|
||||||
|
ALTER TABLE `Product` ADD COLUMN `isPrivate` BOOLEAN NOT NULL DEFAULT false;
|
||||||
@@ -52,6 +52,7 @@ model Product {
|
|||||||
userProducts UserProduct[]
|
userProducts UserProduct[]
|
||||||
categoryId Int?
|
categoryId Int?
|
||||||
categoryRef Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
|
categoryRef Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
|
||||||
|
isPrivate Boolean @default(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
model Category {
|
model Category {
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
import { IsInt, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||||
|
|
||||||
export class CreateProductDto {
|
export class CreateProductDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@MaxLength(191)
|
@MaxLength(191)
|
||||||
name!: string;
|
name!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
categoryId?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,12 +94,27 @@ export class ProductsController {
|
|||||||
return this.productsService.findDeleted();
|
return this.productsService.findDeleted();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Inloggad användares egna privata produkter (måste vara före :id)
|
||||||
|
@Get('mine')
|
||||||
|
findMine(@Request() req: { user: { id: number } }) {
|
||||||
|
return this.productsService.findByOwner(req.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
// Tillgänglig för alla inloggade användare
|
// Tillgänglig för alla inloggade användare
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
findOne(@Param('id', ParseIntPipe) id: number) {
|
findOne(@Param('id', ParseIntPipe) id: number) {
|
||||||
return this.productsService.findOne(id);
|
return this.productsService.findOne(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skapa en privat produkt för den inloggade användaren
|
||||||
|
@Post('private')
|
||||||
|
createPrivate(
|
||||||
|
@Body() body: CreateProductDto,
|
||||||
|
@Request() req: { user: { id: number } },
|
||||||
|
) {
|
||||||
|
return this.productsService.createPrivate(body, req.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
@UseGuards(PremiumOrAdminGuard)
|
@UseGuards(PremiumOrAdminGuard)
|
||||||
@Get(':id/suggest-category')
|
@Get(':id/suggest-category')
|
||||||
@Throttle({ default: { ttl: 60_000, limit: 20 } })
|
@Throttle({ default: { ttl: 60_000, limit: 20 } })
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export class ProductsService {
|
|||||||
return this.prisma.product.findMany({
|
return this.prisma.product.findMany({
|
||||||
where: {
|
where: {
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
isPrivate: false,
|
||||||
...(filters?.subcategory ? { subcategory: filters.subcategory } : {}),
|
...(filters?.subcategory ? { subcategory: filters.subcategory } : {}),
|
||||||
...(filters?.tag
|
...(filters?.tag
|
||||||
? { tags: { some: { tag: { name: filters.tag } } } }
|
? { tags: { some: { tag: { name: filters.tag } } } }
|
||||||
@@ -33,6 +34,45 @@ export class ProductsService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findByOwner(userId: number) {
|
||||||
|
return this.prisma.product.findMany({
|
||||||
|
where: { ownerId: userId, isPrivate: true, isActive: true },
|
||||||
|
select: { id: true, name: true, canonicalName: true, categoryId: true },
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async createPrivate(data: CreateProductDto, userId: number) {
|
||||||
|
const name = data.name.trim();
|
||||||
|
// Privata produkters normalizedName är prefixade för att undvika kollision
|
||||||
|
const normalizedName = `private:${userId}:${normalizeName(name)}`;
|
||||||
|
|
||||||
|
const existing = await this.prisma.product.findUnique({
|
||||||
|
where: { normalizedName },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing && existing.isActive) return existing;
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return this.prisma.product.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: { isActive: true, deletedAt: null, name, canonicalName: name },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.product.create({
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
normalizedName,
|
||||||
|
canonicalName: name,
|
||||||
|
isActive: true,
|
||||||
|
isPrivate: true,
|
||||||
|
ownerId: userId,
|
||||||
|
...(data.categoryId != null ? { categoryId: data.categoryId } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async findDuplicateCandidates() {
|
async findDuplicateCandidates() {
|
||||||
const products = await this.prisma.product.findMany({
|
const products = await this.prisma.product.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -107,6 +147,7 @@ export class ProductsService {
|
|||||||
canonicalName: name,
|
canonicalName: name,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
|
...(data.categoryId != null ? { categoryId: data.categoryId } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ class AuthApiPaths {
|
|||||||
|
|
||||||
class ProductApiPaths {
|
class ProductApiPaths {
|
||||||
static const list = '/products';
|
static const list = '/products';
|
||||||
|
static const mine = '/products/mine';
|
||||||
|
static const createPrivate = '/products/private';
|
||||||
static const pending = '/products/pending';
|
static const pending = '/products/pending';
|
||||||
static const aiCategorizeBulk = '/products/ai-categorize-bulk';
|
static const aiCategorizeBulk = '/products/ai-categorize-bulk';
|
||||||
static const deleted = '/products/deleted';
|
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.
|
/// 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].
|
/// Anropas via [CategoryThenProductPicker.show].
|
||||||
class CategoryThenProductPicker {
|
class CategoryThenProductPicker {
|
||||||
CategoryThenProductPicker._();
|
CategoryThenProductPicker._();
|
||||||
@@ -35,12 +38,16 @@ class CategoryThenProductPicker {
|
|||||||
///
|
///
|
||||||
/// [preselectedCategoryId] — om satt scrollas trädet till den noden och den
|
/// [preselectedCategoryId] — om satt scrollas trädet till den noden och den
|
||||||
/// markeras visuellt. Användaren kan fortfarande välja en annan kategori.
|
/// 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(
|
static Future<int?> show(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required List<AdminCategoryNode> categoryTree,
|
required List<AdminCategoryNode> categoryTree,
|
||||||
required List<ProductOption> products,
|
required List<ProductOption> products,
|
||||||
int? currentProductId,
|
int? currentProductId,
|
||||||
int? preselectedCategoryId,
|
int? preselectedCategoryId,
|
||||||
|
Future<ProductOption?> Function(String name, int categoryId)? onCreate,
|
||||||
}) async {
|
}) async {
|
||||||
// Steg 1 — välj kategori
|
// Steg 1 — välj kategori
|
||||||
final selectedCategory = await showModalBottomSheet<AdminCategoryNode>(
|
final selectedCategory = await showModalBottomSheet<AdminCategoryNode>(
|
||||||
@@ -64,6 +71,11 @@ class CategoryThenProductPicker {
|
|||||||
.toList();
|
.toList();
|
||||||
final useList = filtered.isNotEmpty ? filtered : products;
|
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
|
// Steg 2 — välj produkt
|
||||||
if (!context.mounted) return null;
|
if (!context.mounted) return null;
|
||||||
return ProductPickerField.showSheet(
|
return ProductPickerField.showSheet(
|
||||||
@@ -72,6 +84,7 @@ class CategoryThenProductPicker {
|
|||||||
value: currentProductId,
|
value: currentProductId,
|
||||||
label: 'Produkt i "${selectedCategory.name}"',
|
label: 'Produkt i "${selectedCategory.name}"',
|
||||||
categoryFilter: null, // redan förfiltrerat
|
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).
|
/// Returnerar valt produkt-id, null (ingen ändring), eller [_clearSelectionToken] (rensa).
|
||||||
///
|
///
|
||||||
/// [categoryFilter] — om satt visas bara produkter vars categoryId finns i listan.
|
/// [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(
|
static Future<int?> showSheet(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required List<ProductOption> products,
|
required List<ProductOption> products,
|
||||||
@@ -120,6 +121,7 @@ class ProductPickerField extends StatelessWidget {
|
|||||||
String label = 'Produkt',
|
String label = 'Produkt',
|
||||||
String? initialQuery,
|
String? initialQuery,
|
||||||
Set<int>? categoryFilter,
|
Set<int>? categoryFilter,
|
||||||
|
Future<ProductOption?> Function(String name)? onCreate,
|
||||||
}) async {
|
}) async {
|
||||||
// Filtrera på kategori om angiven, men visa alla om filtret ger nollresultat
|
// Filtrera på kategori om angiven, men visa alla om filtret ger nollresultat
|
||||||
final baseList = categoryFilter != null && categoryFilter.isNotEmpty
|
final baseList = categoryFilter != null && categoryFilter.isNotEmpty
|
||||||
@@ -134,17 +136,54 @@ class ProductPickerField extends StatelessWidget {
|
|||||||
builder: (sheetContext) {
|
builder: (sheetContext) {
|
||||||
final controller = TextEditingController(text: initialQuery ?? '');
|
final controller = TextEditingController(text: initialQuery ?? '');
|
||||||
var query = initialQuery ?? '';
|
var query = initialQuery ?? '';
|
||||||
|
// Mutable lokal kopia — nya produkter kan läggas till
|
||||||
|
var displayList = useList.toList();
|
||||||
|
|
||||||
return StatefulBuilder(
|
return StatefulBuilder(
|
||||||
builder: (ctx, setModalState) {
|
builder: (ctx, setModalState) {
|
||||||
final normalizedQuery = query.trim().toLowerCase();
|
final normalizedQuery = query.trim().toLowerCase();
|
||||||
final filtered = normalizedQuery.isEmpty
|
final filtered = normalizedQuery.isEmpty
|
||||||
? useList
|
? displayList
|
||||||
: useList
|
: displayList
|
||||||
.where((p) => p.name.toLowerCase().contains(normalizedQuery))
|
.where((p) => p.name.toLowerCase().contains(normalizedQuery))
|
||||||
.toList();
|
.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(
|
return SizedBox(
|
||||||
height: MediaQuery.of(ctx).size.height * 0.85,
|
height: MediaQuery.of(ctx).size.height * 0.85,
|
||||||
@@ -161,12 +200,18 @@ class ProductPickerField extends StatelessWidget {
|
|||||||
Text(label, style: Theme.of(ctx).textTheme.titleMedium),
|
Text(label, style: Theme.of(ctx).textTheme.titleMedium),
|
||||||
if (isFiltered)
|
if (isFiltered)
|
||||||
Text(
|
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),
|
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(
|
TextButton.icon(
|
||||||
onPressed: () => Navigator.pop(ctx, _clearSelectionToken),
|
onPressed: () => Navigator.pop(ctx, _clearSelectionToken),
|
||||||
icon: const Icon(Icons.clear),
|
icon: const Icon(Icons.clear),
|
||||||
|
|||||||
@@ -142,6 +142,23 @@ class AdminRepository {
|
|||||||
.toList();
|
.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 {
|
Future<List<AdminProduct>> listDeletedProducts() async {
|
||||||
final token = await _token();
|
final token = await _token();
|
||||||
final data = await guardedApiCall(
|
final data = await guardedApiCall(
|
||||||
|
|||||||
@@ -44,12 +44,14 @@ class _EditDialog extends StatefulWidget {
|
|||||||
final _ItemEdit current;
|
final _ItemEdit current;
|
||||||
final List<ProductOption> products;
|
final List<ProductOption> products;
|
||||||
final List<AdminCategoryNode> categoryTree;
|
final List<AdminCategoryNode> categoryTree;
|
||||||
|
final Future<ProductOption?> Function(String name, int categoryId)? onCreate;
|
||||||
|
|
||||||
const _EditDialog({
|
const _EditDialog({
|
||||||
required this.item,
|
required this.item,
|
||||||
required this.current,
|
required this.current,
|
||||||
required this.products,
|
required this.products,
|
||||||
required this.categoryTree,
|
required this.categoryTree,
|
||||||
|
this.onCreate,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -62,6 +64,8 @@ class _EditDialogState extends State<_EditDialog> {
|
|||||||
int? _productId;
|
int? _productId;
|
||||||
String? _productName;
|
String? _productName;
|
||||||
_Destination _destination = _Destination.inventory;
|
_Destination _destination = _Destination.inventory;
|
||||||
|
// Lokal lista — utökas om nya produkter skapas under dialogen
|
||||||
|
late List<ProductOption> _localProducts;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -69,6 +73,7 @@ class _EditDialogState extends State<_EditDialog> {
|
|||||||
_productId = widget.current.productId;
|
_productId = widget.current.productId;
|
||||||
_productName = widget.current.productName;
|
_productName = widget.current.productName;
|
||||||
_destination = widget.current.destination;
|
_destination = widget.current.destination;
|
||||||
|
_localProducts = List.of(widget.products);
|
||||||
_quantityCtrl = TextEditingController(
|
_quantityCtrl = TextEditingController(
|
||||||
text: (widget.current.quantity ?? widget.item.quantity)?.toString() ?? '',
|
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)
|
// Hjälpfunktion: välj produkt via tvåstegs-picker (kategori → produkt)
|
||||||
Future<void> openCategoryPicker({int? preselectedCategoryId}) async {
|
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(
|
final id = await CategoryThenProductPicker.show(
|
||||||
context,
|
context,
|
||||||
categoryTree: widget.categoryTree,
|
categoryTree: widget.categoryTree,
|
||||||
products: widget.products,
|
products: _localProducts,
|
||||||
currentProductId: _productId,
|
currentProductId: _productId,
|
||||||
preselectedCategoryId: preselectedCategoryId,
|
preselectedCategoryId: preselectedCategoryId,
|
||||||
|
onCreate: onCreateWrapped,
|
||||||
);
|
);
|
||||||
if (id != null && mounted) {
|
if (id != null && mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_productId = id;
|
_productId = id;
|
||||||
_productName = widget.products
|
_productName = _localProducts
|
||||||
.cast<ProductOption?>()
|
.cast<ProductOption?>()
|
||||||
.firstWhere((p) => p?.id == id, orElse: () => null)
|
.firstWhere((p) => p?.id == id, orElse: () => null)
|
||||||
?.name;
|
?.name;
|
||||||
@@ -178,7 +196,7 @@ class _EditDialogState extends State<_EditDialog> {
|
|||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ProductPickerField(
|
child: ProductPickerField(
|
||||||
products: widget.products,
|
products: _localProducts,
|
||||||
value: _productId,
|
value: _productId,
|
||||||
label: 'Produkt',
|
label: 'Produkt',
|
||||||
onChanged: (id) {
|
onChanged: (id) {
|
||||||
@@ -186,7 +204,7 @@ class _EditDialogState extends State<_EditDialog> {
|
|||||||
_productId = id;
|
_productId = id;
|
||||||
_productName = id == null
|
_productName = id == null
|
||||||
? null
|
? null
|
||||||
: widget.products
|
: _localProducts
|
||||||
.cast<ProductOption?>()
|
.cast<ProductOption?>()
|
||||||
.firstWhere((p) => p?.id == id, orElse: () => null)
|
.firstWhere((p) => p?.id == id, orElse: () => null)
|
||||||
?.name;
|
?.name;
|
||||||
@@ -303,17 +321,28 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
final adminRepo = ref.read(adminRepositoryProvider);
|
final adminRepo = ref.read(adminRepositoryProvider);
|
||||||
final results = await Future.wait([
|
final results = await Future.wait([
|
||||||
api.getJson(ProductApiPaths.list, token: token),
|
api.getJson(ProductApiPaths.list, token: token),
|
||||||
|
api.getJson(ProductApiPaths.mine, token: token),
|
||||||
adminRepo.listCategoryTree(),
|
adminRepo.listCategoryTree(),
|
||||||
]);
|
]);
|
||||||
final data = results[0];
|
final globalData = results[0];
|
||||||
final list = data is List ? data : ((data as Map<String, dynamic>?)?['items'] as List? ?? []);
|
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) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_products = list
|
_products = [
|
||||||
.cast<Map<String, dynamic>>()
|
...globalList
|
||||||
.map((e) => (id: e['id'] as int, name: e['name'] as String, categoryId: (e['categoryId'] as num?)?.toInt()))
|
.cast<Map<String, dynamic>>()
|
||||||
.toList();
|
.map((e) => (id: e['id'] as int, name: (e['canonicalName'] ?? e['name']) as String, categoryId: (e['categoryId'] as num?)?.toInt())),
|
||||||
_categoryTree = results[1] as List<AdminCategoryNode>;
|
...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;
|
_loadingProducts = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -413,7 +442,35 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
|
|
||||||
final result = await showDialog<_ItemEdit>(
|
final result = await showDialog<_ItemEdit>(
|
||||||
context: context,
|
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) {
|
if (result != null && mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|||||||
Reference in New Issue
Block a user