feat: Add functionality to move inventory items to pantry and enhance pantry management
Test Suite / test (24.15.0) (push) Has been cancelled
Test Suite / test (24.15.0) (push) Has been cancelled
- Implemented moveInventoryItemToPantry method in InventoryRepository to facilitate moving items from inventory to pantry. - Enhanced InventoryScreen with a new header section providing context about the inventory. - Added a button in SwipeableInventoryTile to move items to pantry with appropriate error handling. - Introduced movePantryItemToInventory method in PantryRepository to support moving items back to inventory. - Refactored PantryScreen to rename _addToInventory to _moveToInventory for clarity and updated UI to reflect changes. - Added AdminPantryItem model to represent pantry items in the admin panel. - Created AdminPantryPanel for managing pantry items, including moving items to inventory and listing users. - Developed AdminPrivateProductsPanel for managing private products, allowing promotion to global products.
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
BadRequestException,
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
@@ -25,15 +26,33 @@ export class InventoryController {
|
|||||||
@Roles('admin')
|
@Roles('admin')
|
||||||
@Get('admin')
|
@Get('admin')
|
||||||
findAllAdmin(
|
findAllAdmin(
|
||||||
@Query('userId', new ParseIntPipe({ optional: true })) userId?: number,
|
@Query('userId') userIdRaw?: string,
|
||||||
@Query('sort') sort?: string,
|
@Query('sort') sort?: string,
|
||||||
) {
|
) {
|
||||||
|
const userId = this.parseOptionalIntQuery(userIdRaw);
|
||||||
return this.inventoryService.findAllAdmin({
|
return this.inventoryService.findAllAdmin({
|
||||||
userId,
|
userId,
|
||||||
sort,
|
sort,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private parseOptionalIntQuery(value: string | undefined): number | undefined {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (trimmed.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^\d+$/.test(trimmed)) {
|
||||||
|
throw new BadRequestException('Validation failed (numeric string is expected)');
|
||||||
|
}
|
||||||
|
|
||||||
|
return Number(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
@Roles('admin')
|
@Roles('admin')
|
||||||
@Post('admin')
|
@Post('admin')
|
||||||
createAdmin(
|
createAdmin(
|
||||||
@@ -134,4 +153,18 @@ findConsumptionHistory(
|
|||||||
) {
|
) {
|
||||||
return this.inventoryService.remove(id, user.userId);
|
return this.inventoryService.remove(id, user.userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post(':id/move-to-pantry')
|
||||||
|
moveToPantry(
|
||||||
|
@CurrentUser() user: { userId: number },
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
) {
|
||||||
|
return this.inventoryService.moveToPantry(id, user.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Roles('admin')
|
||||||
|
@Post('admin/:id/move-to-pantry')
|
||||||
|
moveToPantryAdmin(@Param('id', ParseIntPipe) id: number) {
|
||||||
|
return this.inventoryService.moveToPantryAdmin(id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -348,6 +348,51 @@ export class InventoryService {
|
|||||||
return this.prisma.inventoryItem.delete({ where: { id } });
|
return this.prisma.inventoryItem.delete({ where: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async moveInventoryItemToPantryCore(item: {
|
||||||
|
id: number;
|
||||||
|
userId: number;
|
||||||
|
productId: number;
|
||||||
|
location: string | null;
|
||||||
|
}) {
|
||||||
|
const existingPantryItem = await this.prisma.pantryItem.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_productId: {
|
||||||
|
userId: item.userId,
|
||||||
|
productId: item.productId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingPantryItem) {
|
||||||
|
throw new BadRequestException('Produkten finns redan i baslagret');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const pantryItem = await tx.pantryItem.create({
|
||||||
|
data: {
|
||||||
|
userId: item.userId,
|
||||||
|
productId: item.productId,
|
||||||
|
location: item.location?.trim() || null,
|
||||||
|
},
|
||||||
|
include: { product: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.inventoryItem.delete({ where: { id: item.id } });
|
||||||
|
|
||||||
|
return pantryItem;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async moveToPantry(id: number, userId: number) {
|
||||||
|
const item = await this.findInventoryItemByIdOrThrow(id, userId);
|
||||||
|
return this.moveInventoryItemToPantryCore(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
async moveToPantryAdmin(id: number) {
|
||||||
|
const item = await this.findInventoryItemAnyByIdOrThrow(id);
|
||||||
|
return this.moveInventoryItemToPantryCore(item);
|
||||||
|
}
|
||||||
|
|
||||||
async updateAdmin(id: number, data: UpdateInventoryDto) {
|
async updateAdmin(id: number, data: UpdateInventoryDto) {
|
||||||
await this.findInventoryItemAnyByIdOrThrow(id);
|
await this.findInventoryItemAnyByIdOrThrow(id);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Post } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Post, Query } from '@nestjs/common';
|
||||||
import { PantryService } from './pantry.service';
|
import { PantryService } from './pantry.service';
|
||||||
import { CreatePantryItemDto } from './dto/create-pantry-item.dto';
|
import { CreatePantryItemDto } from './dto/create-pantry-item.dto';
|
||||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
|
import { Roles } from '../auth/decorators/roles.decorator';
|
||||||
|
import { CreateInventoryDto } from '../inventory/dto/create-inventory.dto';
|
||||||
|
|
||||||
@Controller('pantry')
|
@Controller('pantry')
|
||||||
export class PantryController {
|
export class PantryController {
|
||||||
@@ -20,6 +22,15 @@ export class PantryController {
|
|||||||
return this.pantryService.create(user.userId, body);
|
return this.pantryService.create(user.userId, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Roles('admin')
|
||||||
|
@Get('admin')
|
||||||
|
findAllAdmin(@Query('userId') userIdRaw?: string) {
|
||||||
|
const userId = userIdRaw == null || userIdRaw.trim() === '' ? undefined : Number(userIdRaw);
|
||||||
|
return this.pantryService.findAllAdmin({
|
||||||
|
userId: Number.isFinite(userId as number) ? (userId as number) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
remove(
|
remove(
|
||||||
@CurrentUser() user: { userId: number },
|
@CurrentUser() user: { userId: number },
|
||||||
@@ -27,4 +38,28 @@ export class PantryController {
|
|||||||
) {
|
) {
|
||||||
return this.pantryService.remove(user.userId, id);
|
return this.pantryService.remove(user.userId, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Roles('admin')
|
||||||
|
@Delete('admin/:id')
|
||||||
|
removeAdmin(@Param('id', ParseIntPipe) id: number) {
|
||||||
|
return this.pantryService.removeAdmin(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/move-to-inventory')
|
||||||
|
moveToInventory(
|
||||||
|
@CurrentUser() user: { userId: number },
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
@Body() body: CreateInventoryDto,
|
||||||
|
) {
|
||||||
|
return this.pantryService.moveToInventory(user.userId, id, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Roles('admin')
|
||||||
|
@Post('admin/:id/move-to-inventory')
|
||||||
|
moveToInventoryAdmin(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
@Body() body: CreateInventoryDto,
|
||||||
|
) {
|
||||||
|
return this.pantryService.moveToInventoryAdmin(id, body);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { Injectable, ConflictException, NotFoundException } from '@nestjs/common';
|
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { CreatePantryItemDto } from './dto/create-pantry-item.dto';
|
import { CreatePantryItemDto } from './dto/create-pantry-item.dto';
|
||||||
|
import { CreateInventoryDto } from '../inventory/dto/create-inventory.dto';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
type PantryQuery = {
|
||||||
|
userId?: number;
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PantryService {
|
export class PantryService {
|
||||||
@@ -18,6 +24,38 @@ export class PantryService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
findAllAdmin(query?: PantryQuery) {
|
||||||
|
return this.prisma.pantryItem.findMany({
|
||||||
|
where: typeof query?.userId === 'number' ? { userId: query.userId } : {},
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
product: {
|
||||||
|
include: {
|
||||||
|
categoryRef: {
|
||||||
|
include: {
|
||||||
|
parent: {
|
||||||
|
include: {
|
||||||
|
parent: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [
|
||||||
|
{ user: { username: 'asc' } },
|
||||||
|
{ product: { name: 'asc' } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async create(userId: number, data: CreatePantryItemDto) {
|
async create(userId: number, data: CreatePantryItemDto) {
|
||||||
const existing = await this.prisma.pantryItem.findUnique({
|
const existing = await this.prisma.pantryItem.findUnique({
|
||||||
where: {
|
where: {
|
||||||
@@ -53,4 +91,68 @@ export class PantryService {
|
|||||||
|
|
||||||
return this.prisma.pantryItem.delete({ where: { id } });
|
return this.prisma.pantryItem.delete({ where: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async removeAdmin(id: number) {
|
||||||
|
const item = await this.prisma.pantryItem.findUnique({ where: { id } });
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
throw new NotFoundException(`PantryItem med id ${id} hittades inte`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.pantryItem.delete({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async movePantryItemToInventoryCore(
|
||||||
|
item: { id: number; userId: number; productId: number; location: string | null },
|
||||||
|
data: CreateInventoryDto,
|
||||||
|
) {
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const inventoryItem = await tx.inventoryItem.create({
|
||||||
|
data: {
|
||||||
|
userId: item.userId,
|
||||||
|
productId: item.productId,
|
||||||
|
quantity: new Prisma.Decimal(data.quantity),
|
||||||
|
unit: data.unit.trim(),
|
||||||
|
location: data.location?.trim() || item.location || undefined,
|
||||||
|
purchaseDate: data.purchaseDate ? new Date(data.purchaseDate) : undefined,
|
||||||
|
bestBeforeDate: data.bestBeforeDate ? new Date(data.bestBeforeDate) : undefined,
|
||||||
|
brand: data.brand?.trim() || undefined,
|
||||||
|
origin: data.origin?.trim() || undefined,
|
||||||
|
receiptName: data.receiptName?.trim() || undefined,
|
||||||
|
opened: data.opened ?? false,
|
||||||
|
suitableFor: data.suitableFor?.trim() || undefined,
|
||||||
|
comment: data.comment?.trim() || undefined,
|
||||||
|
},
|
||||||
|
include: { product: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.pantryItem.delete({ where: { id: item.id } });
|
||||||
|
|
||||||
|
return inventoryItem;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async moveToInventory(userId: number, pantryItemId: number, data: CreateInventoryDto) {
|
||||||
|
const item = await this.prisma.pantryItem.findFirst({
|
||||||
|
where: { id: pantryItemId, userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
throw new NotFoundException(`PantryItem med id ${pantryItemId} hittades inte`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.movePantryItemToInventoryCore(item, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async moveToInventoryAdmin(pantryItemId: number, data: CreateInventoryDto) {
|
||||||
|
const item = await this.prisma.pantryItem.findUnique({
|
||||||
|
where: { id: pantryItemId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
throw new NotFoundException(`PantryItem med id ${pantryItemId} hittades inte`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.movePantryItemToInventoryCore(item, data);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,12 @@ export class ProductsController {
|
|||||||
return this.productsService.findPending();
|
return this.productsService.findPending();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Roles('admin')
|
||||||
|
@Get('private')
|
||||||
|
findPrivate() {
|
||||||
|
return this.productsService.findPrivate();
|
||||||
|
}
|
||||||
|
|
||||||
@Roles('admin')
|
@Roles('admin')
|
||||||
@Post('ai-categorize-bulk')
|
@Post('ai-categorize-bulk')
|
||||||
@Throttle({ default: { ttl: 60_000, limit: 5 } })
|
@Throttle({ default: { ttl: 60_000, limit: 5 } })
|
||||||
@@ -202,6 +208,15 @@ export class ProductsController {
|
|||||||
return this.productsService.update(id, body);
|
return this.productsService.update(id, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Roles('admin')
|
||||||
|
@Post('private/:id/promote')
|
||||||
|
promotePrivateToGlobal(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
@Request() req: { user: { id: number } },
|
||||||
|
) {
|
||||||
|
return this.productsService.promotePrivateToGlobal(id, req.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
@Roles('admin')
|
@Roles('admin')
|
||||||
@Delete(':id/permanent')
|
@Delete(':id/permanent')
|
||||||
permanentDelete(@Param('id', ParseIntPipe) id: number) {
|
permanentDelete(@Param('id', ParseIntPipe) id: number) {
|
||||||
|
|||||||
@@ -435,6 +435,17 @@ export class ProductsService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findPrivate() {
|
||||||
|
return this.prisma.product.findMany({
|
||||||
|
where: { isPrivate: true, isActive: true },
|
||||||
|
include: {
|
||||||
|
categoryRef: { include: { parent: true } },
|
||||||
|
owner: { select: { id: true, username: true } },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async createPending(data: CreateProductDto, userId: number) {
|
async createPending(data: CreateProductDto, userId: number) {
|
||||||
const name = data.name.trim();
|
const name = data.name.trim();
|
||||||
const normalizedName = normalizeName(name);
|
const normalizedName = normalizeName(name);
|
||||||
@@ -470,6 +481,67 @@ export class ProductsService {
|
|||||||
return this.prisma.product.update({ where: { id }, data: { status } });
|
return this.prisma.product.update({ where: { id }, data: { status } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async promotePrivateToGlobal(productId: number, adminUserId: number) {
|
||||||
|
const source = await this.prisma.product.findUnique({
|
||||||
|
where: { id: productId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
canonicalName: true,
|
||||||
|
normalizedName: true,
|
||||||
|
categoryId: true,
|
||||||
|
isPrivate: true,
|
||||||
|
isActive: true,
|
||||||
|
ownerId: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!source) {
|
||||||
|
throw new NotFoundException(`Product with id ${productId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!source.isPrivate) {
|
||||||
|
throw new ForbiddenException('Endast privata produkter kan promoveras till global produkt');
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = (source.canonicalName ?? source.name).trim();
|
||||||
|
const normalizedName = normalizeName(name);
|
||||||
|
|
||||||
|
const existingGlobal = await this.prisma.product.findUnique({
|
||||||
|
where: { normalizedName },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingGlobal && existingGlobal.id !== source.id) {
|
||||||
|
if (!existingGlobal.isActive) {
|
||||||
|
return this.prisma.product.update({
|
||||||
|
where: { id: existingGlobal.id },
|
||||||
|
data: {
|
||||||
|
isActive: true,
|
||||||
|
deletedAt: null,
|
||||||
|
name,
|
||||||
|
canonicalName: name,
|
||||||
|
categoryId: source.categoryId ?? existingGlobal.categoryId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return existingGlobal;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.product.create({
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
normalizedName,
|
||||||
|
canonicalName: name,
|
||||||
|
isActive: true,
|
||||||
|
isPrivate: false,
|
||||||
|
ownerId: adminUserId,
|
||||||
|
deletedAt: null,
|
||||||
|
...(source.categoryId != null ? { categoryId: source.categoryId } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── Privata produkter (användare kan hantera sina egna) ──────────────────────
|
// ── Privata produkter (användare kan hantera sina egna) ──────────────────────
|
||||||
// Hjälpfunktioner för att undvika kodduplicering mellan admin och user-scope
|
// Hjälpfunktioner för att undvika kodduplicering mellan admin och user-scope
|
||||||
|
|
||||||
|
|||||||
@@ -37,3 +37,56 @@ SnackBar buildCopyableErrorSnackBar(BuildContext context, String message) {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget buildCopyableErrorPanel({
|
||||||
|
required BuildContext context,
|
||||||
|
required String message,
|
||||||
|
required VoidCallback onRetry,
|
||||||
|
String title = 'Ett fel uppstod',
|
||||||
|
}) {
|
||||||
|
return Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 720),
|
||||||
|
child: Card(
|
||||||
|
margin: const EdgeInsets.all(16),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
SelectableText(message),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: onRetry,
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
label: Text(context.l10n.retryAction),
|
||||||
|
),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
Clipboard.setData(ClipboardData(text: message));
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(context.l10n.errorDialogCopied)),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.copy_all),
|
||||||
|
label: Text(context.l10n.errorDialogCopy),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ class ProductApiPaths {
|
|||||||
static const list = '/products';
|
static const list = '/products';
|
||||||
static const mine = '/products/mine';
|
static const mine = '/products/mine';
|
||||||
static const createPrivate = '/products/private';
|
static const createPrivate = '/products/private';
|
||||||
|
static const privateList = '/products/private';
|
||||||
|
static String promotePrivate(int id) => '/products/private/$id/promote';
|
||||||
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';
|
||||||
@@ -59,6 +61,8 @@ class InventoryApiPaths {
|
|||||||
static const list = '/inventory';
|
static const list = '/inventory';
|
||||||
static String update(int id) => '/inventory/$id';
|
static String update(int id) => '/inventory/$id';
|
||||||
static String remove(int id) => '/inventory/$id';
|
static String remove(int id) => '/inventory/$id';
|
||||||
|
static String moveToPantry(int id) => '/inventory/$id/move-to-pantry';
|
||||||
|
static String moveToPantryAdmin(int id) => '/inventory/admin/$id/move-to-pantry';
|
||||||
static String consume(int id) => '/inventory/$id/consume';
|
static String consume(int id) => '/inventory/$id/consume';
|
||||||
static String consumptionHistory(int id) => '/inventory/$id/consumption-history';
|
static String consumptionHistory(int id) => '/inventory/$id/consumption-history';
|
||||||
}
|
}
|
||||||
@@ -77,6 +81,7 @@ class AdminInventoryApiPaths {
|
|||||||
}
|
}
|
||||||
static String update(int id) => '/inventory/admin/$id';
|
static String update(int id) => '/inventory/admin/$id';
|
||||||
static String remove(int id) => '/inventory/admin/$id';
|
static String remove(int id) => '/inventory/admin/$id';
|
||||||
|
static String moveToPantry(int id) => '/inventory/admin/$id/move-to-pantry';
|
||||||
static const merge = '/inventory/admin/merge';
|
static const merge = '/inventory/admin/merge';
|
||||||
static String mergePreview(int sourceInventoryId, int targetInventoryId) =>
|
static String mergePreview(int sourceInventoryId, int targetInventoryId) =>
|
||||||
'/inventory/admin/merge-preview?sourceInventoryId=$sourceInventoryId&targetInventoryId=$targetInventoryId';
|
'/inventory/admin/merge-preview?sourceInventoryId=$sourceInventoryId&targetInventoryId=$targetInventoryId';
|
||||||
@@ -85,6 +90,10 @@ class AdminInventoryApiPaths {
|
|||||||
class PantryApiPaths {
|
class PantryApiPaths {
|
||||||
static const list = '/pantry';
|
static const list = '/pantry';
|
||||||
static String remove(int id) => '/pantry/$id';
|
static String remove(int id) => '/pantry/$id';
|
||||||
|
static String moveToInventory(int id) => '/pantry/$id/move-to-inventory';
|
||||||
|
static String moveToInventoryAdmin(int id) => '/pantry/admin/$id/move-to-inventory';
|
||||||
|
static const adminList = '/pantry/admin';
|
||||||
|
static String adminRemove(int id) => '/pantry/admin/$id';
|
||||||
}
|
}
|
||||||
|
|
||||||
class UserApiPaths {
|
class UserApiPaths {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import '../../../core/api/guarded_api_call.dart';
|
|||||||
import '../../auth/data/auth_providers.dart';
|
import '../../auth/data/auth_providers.dart';
|
||||||
import '../domain/admin_ai_categorize_result.dart';
|
import '../domain/admin_ai_categorize_result.dart';
|
||||||
import '../domain/admin_category_node.dart';
|
import '../domain/admin_category_node.dart';
|
||||||
|
import '../domain/admin_pantry_item.dart';
|
||||||
import '../domain/admin_inventory_item.dart';
|
import '../domain/admin_inventory_item.dart';
|
||||||
import '../domain/admin_product.dart';
|
import '../domain/admin_product.dart';
|
||||||
import '../domain/ai_model_info.dart';
|
import '../domain/ai_model_info.dart';
|
||||||
@@ -177,6 +178,9 @@ class AdminRepository {
|
|||||||
Future<List<AdminProduct>> listGlobalProducts() =>
|
Future<List<AdminProduct>> listGlobalProducts() =>
|
||||||
_getList(ProductApiPaths.list, AdminProduct.fromJson, requiresAuth: false);
|
_getList(ProductApiPaths.list, AdminProduct.fromJson, requiresAuth: false);
|
||||||
|
|
||||||
|
Future<List<PendingProduct>> listPrivateProducts() =>
|
||||||
|
_getList(ProductApiPaths.privateList, PendingProduct.fromJson);
|
||||||
|
|
||||||
Future<List<AdminProduct>> listDeletedProducts() =>
|
Future<List<AdminProduct>> listDeletedProducts() =>
|
||||||
_getList(ProductApiPaths.deleted, AdminProduct.fromJson);
|
_getList(ProductApiPaths.deleted, AdminProduct.fromJson);
|
||||||
|
|
||||||
@@ -186,6 +190,13 @@ class AdminRepository {
|
|||||||
Future<void> setProductStatus(int productId, String status) =>
|
Future<void> setProductStatus(int productId, String status) =>
|
||||||
_patchVoid(ProductApiPaths.setStatus(productId), {'status': status});
|
_patchVoid(ProductApiPaths.setStatus(productId), {'status': status});
|
||||||
|
|
||||||
|
Future<AdminProduct> promotePrivateProduct(int productId) =>
|
||||||
|
_post<AdminProduct>(
|
||||||
|
ProductApiPaths.promotePrivate(productId),
|
||||||
|
body: null,
|
||||||
|
parse: (d) => AdminProduct.fromJson(Map<String, dynamic>.from(d as Map)),
|
||||||
|
);
|
||||||
|
|
||||||
Future<void> setProductCategory(int productId, {required int? categoryId}) =>
|
Future<void> setProductCategory(int productId, {required int? categoryId}) =>
|
||||||
_patchVoid(ProductApiPaths.update(productId), {'categoryId': categoryId});
|
_patchVoid(ProductApiPaths.update(productId), {'categoryId': categoryId});
|
||||||
|
|
||||||
@@ -388,6 +399,29 @@ class AdminRepository {
|
|||||||
Future<void> removeAdminInventory(int inventoryId) =>
|
Future<void> removeAdminInventory(int inventoryId) =>
|
||||||
_deleteVoid(AdminInventoryApiPaths.remove(inventoryId));
|
_deleteVoid(AdminInventoryApiPaths.remove(inventoryId));
|
||||||
|
|
||||||
|
Future<void> moveAdminInventoryToPantry(int inventoryId) =>
|
||||||
|
_postVoid(AdminInventoryApiPaths.moveToPantry(inventoryId));
|
||||||
|
|
||||||
|
// ── Admin pantry ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Future<List<AdminPantryItem>> listAdminPantry({int? userId}) {
|
||||||
|
final params = <String, String>{};
|
||||||
|
if (userId != null) params['userId'] = '$userId';
|
||||||
|
final path = params.isEmpty
|
||||||
|
? PantryApiPaths.adminList
|
||||||
|
: '${PantryApiPaths.adminList}?${params.entries.map((e) => '${Uri.encodeQueryComponent(e.key)}=${Uri.encodeQueryComponent(e.value)}').join('&')}';
|
||||||
|
return _getList(path, AdminPantryItem.fromJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> removeAdminPantryItem(int pantryItemId) =>
|
||||||
|
_deleteVoid(PantryApiPaths.adminRemove(pantryItemId));
|
||||||
|
|
||||||
|
Future<void> moveAdminPantryToInventory(
|
||||||
|
int pantryItemId,
|
||||||
|
Map<String, dynamic> body,
|
||||||
|
) =>
|
||||||
|
_postVoid(PantryApiPaths.moveToInventoryAdmin(pantryItemId), body);
|
||||||
|
|
||||||
Future<void> mergeAdminInventory({
|
Future<void> mergeAdminInventory({
|
||||||
required int sourceInventoryId,
|
required int sourceInventoryId,
|
||||||
required int targetInventoryId,
|
required int targetInventoryId,
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
class AdminPantryItem {
|
||||||
|
final int id;
|
||||||
|
final int userId;
|
||||||
|
final String username;
|
||||||
|
final String userEmail;
|
||||||
|
final int productId;
|
||||||
|
final String productName;
|
||||||
|
final String? productCanonicalName;
|
||||||
|
final String? location;
|
||||||
|
|
||||||
|
const AdminPantryItem({
|
||||||
|
required this.id,
|
||||||
|
required this.userId,
|
||||||
|
required this.username,
|
||||||
|
required this.userEmail,
|
||||||
|
required this.productId,
|
||||||
|
required this.productName,
|
||||||
|
this.productCanonicalName,
|
||||||
|
this.location,
|
||||||
|
});
|
||||||
|
|
||||||
|
String get displayName {
|
||||||
|
final canonical = productCanonicalName?.trim();
|
||||||
|
if (canonical != null && canonical.isNotEmpty) return canonical;
|
||||||
|
return productName;
|
||||||
|
}
|
||||||
|
|
||||||
|
factory AdminPantryItem.fromJson(Map<String, dynamic> json) {
|
||||||
|
final user = (json['user'] as Map<String, dynamic>?) ?? const {};
|
||||||
|
final product = (json['product'] as Map<String, dynamic>?) ?? const {};
|
||||||
|
return AdminPantryItem(
|
||||||
|
id: (json['id'] as num).toInt(),
|
||||||
|
userId: (json['userId'] as num).toInt(),
|
||||||
|
username: user['username'] as String? ?? '',
|
||||||
|
userEmail: user['email'] as String? ?? '',
|
||||||
|
productId: (json['productId'] as num).toInt(),
|
||||||
|
productName: product['name'] as String? ?? '',
|
||||||
|
productCanonicalName: product['canonicalName'] as String?,
|
||||||
|
location: json['location'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,26 +55,54 @@ class _AdminAiPanelState extends ConsumerState<AdminAiPanel> {
|
|||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
if (_isLoading) return const Center(child: CircularProgressIndicator());
|
if (_isLoading) return const Center(child: CircularProgressIndicator());
|
||||||
if (_error != null) {
|
if (_error != null) {
|
||||||
return Center(
|
return buildCopyableErrorPanel(
|
||||||
child: Column(
|
context: context,
|
||||||
mainAxisSize: MainAxisSize.min,
|
message: _error!,
|
||||||
children: [
|
onRetry: _load,
|
||||||
Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
|
title: 'Kunde inte läsa AI-modeller',
|
||||||
const SizedBox(height: 16),
|
|
||||||
FilledButton(onPressed: _load, child: Text(context.l10n.retryAction)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('AI', style: theme.textTheme.titleMedium),
|
||||||
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
context.l10n.adminAiDescription,
|
context.l10n.adminAiDescription,
|
||||||
style: theme.textTheme.bodyMedium,
|
style: theme.textTheme.bodyMedium,
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
Chip(label: Text('Models')),
|
||||||
|
Chip(label: Text('Access')),
|
||||||
|
Chip(label: Text('Trigger')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
if (_models.isEmpty)
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Text(
|
||||||
|
'Inga AI-modeller hittades.',
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
..._models.map(
|
..._models.map(
|
||||||
(model) => Card(
|
(model) => Card(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
|
|||||||
@@ -147,15 +147,11 @@ class _AdminAliasesPanelState extends ConsumerState<AdminAliasesPanel> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (_error != null) {
|
if (_error != null) {
|
||||||
return Center(
|
return buildCopyableErrorPanel(
|
||||||
child: Column(
|
context: context,
|
||||||
mainAxisSize: MainAxisSize.min,
|
message: _error!,
|
||||||
children: [
|
onRetry: _load,
|
||||||
Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
|
title: 'Kunde inte läsa alias',
|
||||||
const SizedBox(height: 16),
|
|
||||||
FilledButton(onPressed: _load, child: const Text('Försök igen')),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,10 +202,32 @@ class _AdminAliasesPanelState extends ConsumerState<AdminAliasesPanel> {
|
|||||||
final content = Column(
|
final content = Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('Alias', style: theme.textTheme.titleMedium),
|
||||||
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'Globala alias används som fallback i kvittoimporten. När samma kvittonamn upprepas kan rätt produkt matchas direkt.',
|
'Globala alias används som fallback i kvittoimporten. När samma kvittonamn upprepas kan rätt produkt matchas direkt.',
|
||||||
style: theme.textTheme.bodyMedium,
|
style: theme.textTheme.bodyMedium,
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
Chip(label: Text('Fallback')),
|
||||||
|
Chip(label: Text('Global')),
|
||||||
|
Chip(label: Text('Receipt import')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -267,7 +285,16 @@ class _AdminAliasesPanelState extends ConsumerState<AdminAliasesPanel> {
|
|||||||
onChanged: (value) => setState(() => _search = value),
|
onChanged: (value) => setState(() => _search = value),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
if (filteredAliases.isEmpty) const Text('Inga alias hittades.'),
|
if (filteredAliases.isEmpty)
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Text(
|
||||||
|
'Inga alias hittades.',
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,16 @@ import 'package:go_router/go_router.dart';
|
|||||||
|
|
||||||
import '../../../core/api/api_error_mapper.dart';
|
import '../../../core/api/api_error_mapper.dart';
|
||||||
import '../../../core/l10n/l10n.dart';
|
import '../../../core/l10n/l10n.dart';
|
||||||
import '../../profile/data/profile_repository.dart';
|
import 'admin_ai_panel.dart';
|
||||||
|
import 'admin_aliases_panel.dart';
|
||||||
|
import 'admin_inventory_panel.dart';
|
||||||
|
import 'admin_pantry_panel.dart';
|
||||||
|
import 'admin_private_products_panel.dart';
|
||||||
|
import 'admin_pending_products_panel.dart';
|
||||||
import 'admin_products_panel.dart';
|
import 'admin_products_panel.dart';
|
||||||
|
import '../../profile/data/profile_repository.dart';
|
||||||
|
|
||||||
enum _DatabaseTab { inventory, pantry, products }
|
enum _DatabaseTab { inventory, pantry, products, privateProducts, pending, aliases, ai }
|
||||||
|
|
||||||
class AdminDatabasePanel extends ConsumerStatefulWidget {
|
class AdminDatabasePanel extends ConsumerStatefulWidget {
|
||||||
final bool embedded;
|
final bool embedded;
|
||||||
@@ -76,49 +82,124 @@ class _AdminDatabasePanelState extends ConsumerState<AdminDatabasePanel> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _panelShell({
|
||||||
|
required String title,
|
||||||
|
required String description,
|
||||||
|
required Widget child,
|
||||||
|
}) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(title, style: Theme.of(context).textTheme.titleMedium),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(description),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
child,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
String tabLabel(_DatabaseTab tab) {
|
String tabLabel(_DatabaseTab tab) {
|
||||||
switch (tab) {
|
return switch (tab) {
|
||||||
case _DatabaseTab.inventory:
|
_DatabaseTab.inventory => context.l10n.profileInventoryTab,
|
||||||
return context.l10n.profileInventoryTab;
|
_DatabaseTab.pantry => context.l10n.profilePantryTab,
|
||||||
case _DatabaseTab.pantry:
|
_DatabaseTab.products => context.l10n.profileProductsTab,
|
||||||
return context.l10n.profilePantryTab;
|
_DatabaseTab.privateProducts => 'Privata produkter',
|
||||||
case _DatabaseTab.products:
|
_DatabaseTab.pending => context.l10n.profilePendingTab,
|
||||||
return context.l10n.profileProductsTab;
|
_DatabaseTab.aliases => 'Alias',
|
||||||
}
|
_DatabaseTab.ai => 'AI',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget activeSection;
|
Widget activeSection;
|
||||||
switch (_activeTab) {
|
switch (_activeTab) {
|
||||||
case _DatabaseTab.inventory:
|
case _DatabaseTab.inventory:
|
||||||
activeSection = _sectionCard(
|
activeSection = _panelShell(
|
||||||
icon: Icons.inventory_2_outlined,
|
|
||||||
title: context.l10n.profileInventoryTab,
|
title: context.l10n.profileInventoryTab,
|
||||||
description: context.l10n.profileInventoryDescription,
|
description: 'Granska, filtrera och redigera inventory-poster. Välj användare för att arbeta på en specifik ägares data.',
|
||||||
onPressed: () => context.go('/inventory'),
|
child: const AdminInventoryPanel(embedded: true),
|
||||||
buttonLabel: context.l10n.profileOpenInventory,
|
|
||||||
);
|
);
|
||||||
case _DatabaseTab.pantry:
|
case _DatabaseTab.pantry:
|
||||||
activeSection = _sectionCard(
|
activeSection = _panelShell(
|
||||||
icon: Icons.storefront_outlined,
|
|
||||||
title: context.l10n.profilePantryTab,
|
title: context.l10n.profilePantryTab,
|
||||||
description: context.l10n.profilePantryDescription,
|
description: 'Granska och redigera användarnas baslager. Flytta poster till inventarie eller ta bort dem vid behov.',
|
||||||
onPressed: () => context.go('/baslager'),
|
child: const AdminPantryPanel(embedded: true),
|
||||||
buttonLabel: context.l10n.profileOpenPantry,
|
|
||||||
);
|
);
|
||||||
case _DatabaseTab.products:
|
case _DatabaseTab.products:
|
||||||
activeSection = const AdminProductsPanel(embedded: true);
|
activeSection = _panelShell(
|
||||||
|
title: context.l10n.profileProductsTab,
|
||||||
|
description: 'Hantera globala produkter: kategorisering, restaurering, merge och AI-stöd.',
|
||||||
|
child: const AdminProductsPanel(embedded: true),
|
||||||
|
);
|
||||||
|
case _DatabaseTab.privateProducts:
|
||||||
|
activeSection = _panelShell(
|
||||||
|
title: 'Privata produkter',
|
||||||
|
description: 'Promotera privata produkter till den globala produkt-tabellen.',
|
||||||
|
child: const AdminPrivateProductsPanel(embedded: true),
|
||||||
|
);
|
||||||
|
case _DatabaseTab.pending:
|
||||||
|
activeSection = _panelShell(
|
||||||
|
title: context.l10n.profilePendingTab,
|
||||||
|
description: 'Godkänn eller avslå nya produkter som föreslagits av användare.',
|
||||||
|
child: const AdminPendingProductsPanel(embedded: true),
|
||||||
|
);
|
||||||
|
case _DatabaseTab.aliases:
|
||||||
|
activeSection = _panelShell(
|
||||||
|
title: 'Alias',
|
||||||
|
description: 'Hantera globala alias som används i receipt-importens första matchningssteg.',
|
||||||
|
child: const AdminAliasesPanel(embedded: true),
|
||||||
|
);
|
||||||
|
case _DatabaseTab.ai:
|
||||||
|
activeSection = _panelShell(
|
||||||
|
title: 'AI',
|
||||||
|
description: 'Se vilka AI-modeller som används och hur de är exponerade i systemet.',
|
||||||
|
child: const AdminAiPanel(embedded: true),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return SingleChildScrollView(
|
final header = SingleChildScrollView(
|
||||||
padding: widget.embedded ? EdgeInsets.zero : const EdgeInsets.all(16),
|
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('Databas', style: theme.textTheme.titleMedium),
|
||||||
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
context.l10n.profileDatabaseDescription,
|
'Arbetsyta för data med tydlig scope: inventory och baslager är användarspecifika, produkter är globala eller privata och alias styr importmatchning.',
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
Chip(label: Text('User-scope')),
|
||||||
|
Chip(label: Text('Global scope')),
|
||||||
|
Chip(label: Text('Private products')),
|
||||||
|
Chip(label: Text('Alias')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Card(
|
Card(
|
||||||
@@ -127,15 +208,9 @@ class _AdminDatabasePanelState extends ConsumerState<AdminDatabasePanel> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text('Adminverktyg', style: theme.textTheme.titleMedium),
|
||||||
'Adminverktyg',
|
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text('Uppdatera kategorier manuellt i backend-cachen.', style: theme.textTheme.bodyMedium),
|
||||||
'Uppdatera kategorier manuellt i backend-cachen.',
|
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
@@ -173,8 +248,18 @@ class _AdminDatabasePanelState extends ConsumerState<AdminDatabasePanel> {
|
|||||||
.toList(),
|
.toList(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: widget.embedded ? EdgeInsets.zero : const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Expanded(flex: 2, child: header),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
activeSection,
|
Expanded(flex: 5, child: activeSection),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../../../core/api/api_error_mapper.dart';
|
import '../../../core/api/api_error_mapper.dart';
|
||||||
@@ -278,7 +279,13 @@ class _AdminInventoryPanelState extends ConsumerState<AdminInventoryPanel> {
|
|||||||
width: 460,
|
width: 460,
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
Text(
|
||||||
|
'Välj två poster för samma användare, produkt och enhet. Source tas bort och target behålls.',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
DropdownButtonFormField<int>(
|
DropdownButtonFormField<int>(
|
||||||
initialValue: sourceId,
|
initialValue: sourceId,
|
||||||
items: _items
|
items: _items
|
||||||
@@ -454,12 +461,58 @@ class _AdminInventoryPanelState extends ConsumerState<AdminInventoryPanel> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
if (_isLoading) {
|
if (_isLoading) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_error != null) {
|
if (_error != null) {
|
||||||
return Center(child: Text(_error!));
|
final message = _error!;
|
||||||
|
return Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 720),
|
||||||
|
child: Card(
|
||||||
|
margin: const EdgeInsets.all(16),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Kunde inte läsa inventory-data',
|
||||||
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
SelectableText(message),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: _load,
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
label: const Text('Försök igen'),
|
||||||
|
),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
Clipboard.setData(ClipboardData(text: message));
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Felmeddelande kopierat.')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.copy_all),
|
||||||
|
label: const Text('Kopiera fel'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final filtered = _filtered;
|
final filtered = _filtered;
|
||||||
@@ -467,6 +520,33 @@ class _AdminInventoryPanelState extends ConsumerState<AdminInventoryPanel> {
|
|||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('Inventory', style: theme.textTheme.titleMedium),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Här arbetar du på användarnas inventory-poster. Du kan filtrera per användare, justera mängder, flytta poster till baslager och slå ihop duplicerade rader.',
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
Chip(label: Text('User-scope')),
|
||||||
|
Chip(label: Text('Merge')),
|
||||||
|
Chip(label: Text('Flytta till baslager')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
SizedBox(
|
||||||
@@ -551,7 +631,23 @@ class _AdminInventoryPanelState extends ConsumerState<AdminInventoryPanel> {
|
|||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Card(
|
child: Card(
|
||||||
child: ListView.separated(
|
child: filtered.isEmpty
|
||||||
|
? Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text('Inventory', style: theme.textTheme.titleMedium),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Inga inventory-poster hittades med nuvarande filter.',
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ListView.separated(
|
||||||
itemCount: filtered.length,
|
itemCount: filtered.length,
|
||||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
@@ -565,6 +661,26 @@ class _AdminInventoryPanelState extends ConsumerState<AdminInventoryPanel> {
|
|||||||
trailing: Row(
|
trailing: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Flytta till baslager',
|
||||||
|
onPressed: () async {
|
||||||
|
try {
|
||||||
|
await ref.read(adminRepositoryProvider).moveAdminInventoryToPantry(item.id);
|
||||||
|
if (!mounted) return;
|
||||||
|
await _load();
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Flyttade "${item.displayName}" till baslager.')),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.storefront_outlined),
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Ändra',
|
tooltip: 'Ändra',
|
||||||
onPressed: () => _editItem(item),
|
onPressed: () => _editItem(item),
|
||||||
@@ -681,7 +797,15 @@ class _InventoryFormDialogState extends State<_InventoryFormDialog> {
|
|||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
Text(
|
||||||
|
widget.initial == null
|
||||||
|
? 'Skapa en ny inventory-rad för en användare. Välj produkt, mängd, enhet och valfria metadata.'
|
||||||
|
: 'Ändra den valda inventory-raden. Produkt, mängd, enhet och metadata kan justeras utan att byta ägare.',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
if (widget.initial == null) ...[
|
if (widget.initial == null) ...[
|
||||||
DropdownButtonFormField<int>(
|
DropdownButtonFormField<int>(
|
||||||
initialValue: _ownerUserId,
|
initialValue: _ownerUserId,
|
||||||
|
|||||||
@@ -0,0 +1,341 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../../core/api/api_error_mapper.dart';
|
||||||
|
import '../../../core/forms/form_options.dart';
|
||||||
|
import '../../../core/l10n/l10n.dart';
|
||||||
|
import '../data/admin_repository.dart';
|
||||||
|
import '../domain/admin_pantry_item.dart';
|
||||||
|
import '../domain/user_admin.dart';
|
||||||
|
|
||||||
|
class AdminPantryPanel extends ConsumerStatefulWidget {
|
||||||
|
final bool embedded;
|
||||||
|
|
||||||
|
const AdminPantryPanel({super.key, this.embedded = false});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<AdminPantryPanel> createState() => _AdminPantryPanelState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AdminPantryPanelState extends ConsumerState<AdminPantryPanel> {
|
||||||
|
bool _isLoading = true;
|
||||||
|
String? _error;
|
||||||
|
int? _selectedUserId;
|
||||||
|
List<AdminPantryItem> _items = [];
|
||||||
|
List<UserAdmin> _users = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final results = await Future.wait<dynamic>([
|
||||||
|
ref.read(adminRepositoryProvider).listAdminPantry(userId: _selectedUserId),
|
||||||
|
ref.read(adminRepositoryProvider).listUsers(),
|
||||||
|
]);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_items = results[0] as List<AdminPantryItem>;
|
||||||
|
_users = results[1] as List<UserAdmin>;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _error = mapErrorToUserMessage(e, context));
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _isLoading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _moveToInventory(AdminPantryItem item) async {
|
||||||
|
final quantityController = TextEditingController(text: '1');
|
||||||
|
String selectedUnit = 'st';
|
||||||
|
String? selectedLocation;
|
||||||
|
String? formError;
|
||||||
|
|
||||||
|
final payload = await showDialog<Map<String, dynamic>>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) {
|
||||||
|
return StatefulBuilder(
|
||||||
|
builder: (ctx, setDialogState) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text(context.l10n.pantryAddToInventoryTitle(item.displayName)),
|
||||||
|
content: SizedBox(
|
||||||
|
width: 380,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: quantityController,
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: context.l10n.inventoryQuantityDisplayLabel,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
initialValue: selectedUnit,
|
||||||
|
isExpanded: true,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: context.l10n.unitLabel,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
items: unitOptions
|
||||||
|
.map((option) => DropdownMenuItem<String>(
|
||||||
|
value: option.value,
|
||||||
|
child: Text(option.label),
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value == null) return;
|
||||||
|
setDialogState(() => selectedUnit = value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
initialValue: selectedLocation,
|
||||||
|
isExpanded: true,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: context.l10n.locationOptionalLabel,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
items: [
|
||||||
|
DropdownMenuItem<String>(
|
||||||
|
value: null,
|
||||||
|
child: Text(context.l10n.pantryNoLocation),
|
||||||
|
),
|
||||||
|
...inventoryLocationOptions.map(
|
||||||
|
(location) => DropdownMenuItem<String>(
|
||||||
|
value: location,
|
||||||
|
child: Text(location),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onChanged: (value) {
|
||||||
|
setDialogState(() => selectedLocation = value);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (formError != null) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
formError!,
|
||||||
|
style: TextStyle(color: Theme.of(ctx).colorScheme.error),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx),
|
||||||
|
child: Text(context.l10n.cancelAction),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () {
|
||||||
|
final quantity = double.tryParse(
|
||||||
|
quantityController.text.trim().replaceAll(',', '.'),
|
||||||
|
);
|
||||||
|
if (quantity == null || quantity <= 0) {
|
||||||
|
setDialogState(() {
|
||||||
|
formError = context.l10n.pantryInvalidQuantity;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Navigator.pop(ctx, {
|
||||||
|
'quantity': quantity,
|
||||||
|
'unit': selectedUnit,
|
||||||
|
'location': selectedLocation,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: Text(context.l10n.addAction),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
quantityController.dispose();
|
||||||
|
if (payload == null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ref.read(adminRepositoryProvider).moveAdminPantryToInventory(
|
||||||
|
item.id,
|
||||||
|
{
|
||||||
|
'productId': item.productId,
|
||||||
|
'quantity': payload['quantity'] as double,
|
||||||
|
'unit': payload['unit'] as String,
|
||||||
|
if (payload['location'] != null) 'location': payload['location'] as String,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
await _load();
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Flyttade "${item.displayName}" till inventarie.')),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
if (_isLoading) return const Center(child: CircularProgressIndicator());
|
||||||
|
if (_error != null) {
|
||||||
|
return buildCopyableErrorPanel(
|
||||||
|
context: context,
|
||||||
|
message: _error!,
|
||||||
|
onRetry: _load,
|
||||||
|
title: 'Kunde inte läsa admin pantry',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('Baslager', style: theme.textTheme.titleMedium),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Här ser du användarnas pantryposter. Flytta dem tillbaka till inventarie eller ta bort poster som inte längre ska ligga kvar.',
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
Chip(label: Text('User-scope')),
|
||||||
|
Chip(label: Text('Flytta till inventarie')),
|
||||||
|
Chip(label: Text('Ta bort')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: DropdownButtonFormField<int>(
|
||||||
|
initialValue: _selectedUserId,
|
||||||
|
decoration: const InputDecoration(labelText: 'Filtrera användare'),
|
||||||
|
items: [
|
||||||
|
const DropdownMenuItem<int>(
|
||||||
|
value: null,
|
||||||
|
child: Text('Alla användare'),
|
||||||
|
),
|
||||||
|
..._users.map(
|
||||||
|
(user) => DropdownMenuItem<int>(
|
||||||
|
value: user.id,
|
||||||
|
child: Text('${user.displayName} (${user.username})'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() => _selectedUserId = value);
|
||||||
|
_load();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: _load,
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
label: const Text('Uppdatera'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Expanded(
|
||||||
|
child: _items.isEmpty
|
||||||
|
? Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('Baslager', style: theme.textTheme.titleMedium),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Här ser du användarnas pantryposter. Flytta dem tillbaka till inventarie eller ta bort poster som inte längre ska ligga kvar.',
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Inga pantry-poster hittades.',
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ListView.separated(
|
||||||
|
itemCount: _items.length,
|
||||||
|
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final item = _items[index];
|
||||||
|
return ListTile(
|
||||||
|
title: Text(item.displayName),
|
||||||
|
subtitle: Text(
|
||||||
|
'${item.username} (${item.userEmail})${item.location == null || item.location!.trim().isEmpty ? '' : ' · ${item.location}'}',
|
||||||
|
),
|
||||||
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Flytta till inventarie',
|
||||||
|
icon: const Icon(Icons.inventory_2_outlined),
|
||||||
|
onPressed: () => _moveToInventory(item),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Ta bort',
|
||||||
|
icon: const Icon(Icons.delete_outline, color: Colors.red),
|
||||||
|
onPressed: () async {
|
||||||
|
try {
|
||||||
|
await ref.read(adminRepositoryProvider).removeAdminPantryItem(item.id);
|
||||||
|
if (!mounted) return;
|
||||||
|
await _load();
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,25 +69,33 @@ class _AdminPendingProductsPanelState
|
|||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
if (_isLoading) return const Center(child: CircularProgressIndicator());
|
if (_isLoading) return const Center(child: CircularProgressIndicator());
|
||||||
if (_error != null) {
|
if (_error != null) {
|
||||||
return Center(
|
return buildCopyableErrorPanel(
|
||||||
child: Column(
|
context: context,
|
||||||
mainAxisSize: MainAxisSize.min,
|
message: _error!,
|
||||||
children: [
|
onRetry: _load,
|
||||||
Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
|
title: 'Pending produkter',
|
||||||
const SizedBox(height: 16),
|
|
||||||
FilledButton(onPressed: _load, child: Text(context.l10n.retryAction)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (_products.isEmpty) {
|
if (_products.isEmpty) {
|
||||||
return Card(
|
return Card(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Text(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('Pending produkter', style: theme.textTheme.titleMedium),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Detta är användarsubmittade produkter som väntar på att godkännas eller avslås innan de blir en del av den globala produkt-tabellen.',
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
context.l10n.adminNoPendingProducts,
|
context.l10n.adminNoPendingProducts,
|
||||||
style: theme.textTheme.bodyMedium,
|
style: theme.textTheme.bodyMedium,
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -145,10 +153,32 @@ class _AdminPendingProductsPanelState
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('Pending produkter', style: theme.textTheme.titleMedium),
|
||||||
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
context.l10n.adminPendingDescription,
|
'Detta är användarsubmittade produkter som väntar på att godkännas eller avslås innan de blir en del av den globala produkt-tabellen.',
|
||||||
style: theme.textTheme.bodyMedium,
|
style: theme.textTheme.bodyMedium,
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
Chip(label: Text('User-suggested')),
|
||||||
|
Chip(label: Text('Approve/Reject')),
|
||||||
|
Chip(label: Text('Global promotion')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
content,
|
content,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../../core/api/api_error_mapper.dart';
|
||||||
|
import '../data/admin_repository.dart';
|
||||||
|
import '../domain/pending_product.dart';
|
||||||
|
|
||||||
|
class AdminPrivateProductsPanel extends ConsumerStatefulWidget {
|
||||||
|
final bool embedded;
|
||||||
|
|
||||||
|
const AdminPrivateProductsPanel({super.key, this.embedded = false});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<AdminPrivateProductsPanel> createState() =>
|
||||||
|
_AdminPrivateProductsPanelState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AdminPrivateProductsPanelState extends ConsumerState<AdminPrivateProductsPanel> {
|
||||||
|
bool _isLoading = true;
|
||||||
|
String? _error;
|
||||||
|
List<PendingProduct> _products = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final products = await ref.read(adminRepositoryProvider).listPrivateProducts();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _products = products);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _error = mapErrorToUserMessage(e, context));
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _isLoading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _promote(PendingProduct product) async {
|
||||||
|
try {
|
||||||
|
await ref.read(adminRepositoryProvider).promotePrivateProduct(product.id);
|
||||||
|
if (!mounted) return;
|
||||||
|
await _load();
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Promoterade "${product.displayName}" till global produkt.')),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
if (_isLoading) return const Center(child: CircularProgressIndicator());
|
||||||
|
if (_error != null) {
|
||||||
|
return buildCopyableErrorPanel(
|
||||||
|
context: context,
|
||||||
|
message: _error!,
|
||||||
|
onRetry: _load,
|
||||||
|
title: 'Kunde inte läsa privata produkter',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_products.isEmpty) {
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('Privata produkter', style: theme.textTheme.titleMedium),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Privata produkter är användarägda poster som kan lyftas upp till globala produkter när de ska återanvändas brett.',
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Inga aktiva privata produkter hittades.',
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final list = ListView.separated(
|
||||||
|
shrinkWrap: false,
|
||||||
|
physics: null,
|
||||||
|
itemCount: _products.length,
|
||||||
|
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final product = _products[index];
|
||||||
|
return Card(
|
||||||
|
child: ListTile(
|
||||||
|
leading: const Icon(Icons.publish_outlined),
|
||||||
|
title: Text(product.displayName),
|
||||||
|
subtitle: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (product.categoryPath != null)
|
||||||
|
Text('Kategori: ${product.categoryPath}'),
|
||||||
|
Text('Ägare: ${product.ownerUsername ?? '—'}'),
|
||||||
|
Text('Skapad: ${product.createdAt == null ? '—' : MaterialLocalizations.of(context).formatShortDate(product.createdAt!)}'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
trailing: FilledButton(
|
||||||
|
onPressed: () => _promote(product),
|
||||||
|
child: const Text('Promote'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('Privata produkter', style: theme.textTheme.titleMedium),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Privata produkter kan promoveras till globala produkter utan att användarens privata kopia försvinner.',
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Expanded(child: list),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -199,7 +199,10 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(context.l10n.adminMergeProductsHint),
|
Text(
|
||||||
|
'Välj vilken produkt som ska behållas som mål. Källprodukten slås ihop i målet och relaterad inventarie flyttas med.',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
SegmentedButton<int>(
|
SegmentedButton<int>(
|
||||||
segments: [
|
segments: [
|
||||||
@@ -330,6 +333,11 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
|
|||||||
children: [
|
children: [
|
||||||
Text('Produkt-ID: ${product.id}'),
|
Text('Produkt-ID: ${product.id}'),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'Sätt ett kanoniskt namn som ska användas i gränssnitt och vid sammanslagning. Det ändrar inte produkt-ID.',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
TextField(
|
TextField(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
@@ -491,7 +499,7 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
|
|||||||
void _showError(Object e) {
|
void _showError(Object e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text(mapErrorToUserMessage(e, context))),
|
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -530,21 +538,45 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
|
|||||||
|
|
||||||
if (_isLoading) return const Center(child: CircularProgressIndicator());
|
if (_isLoading) return const Center(child: CircularProgressIndicator());
|
||||||
if (_error != null) {
|
if (_error != null) {
|
||||||
return Center(
|
return buildCopyableErrorPanel(
|
||||||
child: Column(
|
context: context,
|
||||||
mainAxisSize: MainAxisSize.min,
|
message: _error!,
|
||||||
children: [
|
onRetry: _load,
|
||||||
Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
|
title: 'Globala produkter',
|
||||||
const SizedBox(height: 16),
|
|
||||||
FilledButton(onPressed: _load, child: Text(context.l10n.retryAction)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final content = Column(
|
final content = Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('Globala produkter', style: theme.textTheme.titleMedium),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Detta är den globala produkt-tabellen. Här ändrar du kategorier, sammanslår poster, återställer borttagna produkter och styr AI-assisterad kategorisering.',
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: const [
|
||||||
|
Chip(label: Text('Global')),
|
||||||
|
Chip(label: Text('CRUD')),
|
||||||
|
Chip(label: Text('Merge')),
|
||||||
|
Chip(label: Text('AI-kategorisering')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
TextField(
|
TextField(
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: context.l10n.adminSearchProduct,
|
labelText: context.l10n.adminSearchProduct,
|
||||||
@@ -665,7 +697,15 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
if (filtered.isEmpty)
|
if (filtered.isEmpty)
|
||||||
Text(context.l10n.adminNoProductsFound)
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Text(
|
||||||
|
context.l10n.adminNoProductsFound,
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
else
|
else
|
||||||
...filtered.map(
|
...filtered.map(
|
||||||
(product) => Card(
|
(product) => Card(
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../../core/l10n/l10n.dart';
|
import '../../../core/l10n/l10n.dart';
|
||||||
import 'admin_ai_panel.dart';
|
|
||||||
import 'admin_aliases_panel.dart';
|
|
||||||
import 'admin_database_panel.dart';
|
import 'admin_database_panel.dart';
|
||||||
import 'admin_inventory_panel.dart';
|
|
||||||
import 'admin_pending_products_panel.dart';
|
|
||||||
import 'admin_products_panel.dart';
|
|
||||||
import 'admin_users_panel.dart';
|
import 'admin_users_panel.dart';
|
||||||
|
|
||||||
class AdminScreen extends ConsumerStatefulWidget {
|
class AdminScreen extends ConsumerStatefulWidget {
|
||||||
@@ -19,10 +14,40 @@ class AdminScreen extends ConsumerStatefulWidget {
|
|||||||
class _AdminScreenState extends ConsumerState<AdminScreen> {
|
class _AdminScreenState extends ConsumerState<AdminScreen> {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
return DefaultTabController(
|
return DefaultTabController(
|
||||||
length: 7,
|
length: 2,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
|
||||||
|
child: Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('Admin', style: theme.textTheme.titleMedium),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Användare är för konton och roller. Databas är arbetsytan för inventarie, baslager, produkter, alias och importflöden.',
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
Chip(label: Text('Konton')),
|
||||||
|
Chip(label: Text('Databas')),
|
||||||
|
Chip(label: Text('Privat + globalt')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
Material(
|
Material(
|
||||||
color: Theme.of(context).colorScheme.surface,
|
color: Theme.of(context).colorScheme.surface,
|
||||||
child: TabBar(
|
child: TabBar(
|
||||||
@@ -30,11 +55,6 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
|
|||||||
tabs: [
|
tabs: [
|
||||||
Tab(text: context.l10n.profileUsersTab, icon: const Icon(Icons.people_outline)),
|
Tab(text: context.l10n.profileUsersTab, icon: const Icon(Icons.people_outline)),
|
||||||
const Tab(text: 'Databas', icon: Icon(Icons.storage_outlined)),
|
const Tab(text: 'Databas', icon: Icon(Icons.storage_outlined)),
|
||||||
const Tab(text: 'Inventory', icon: Icon(Icons.inventory_outlined)),
|
|
||||||
const Tab(text: 'Produkter', icon: Icon(Icons.inventory_2_outlined)),
|
|
||||||
Tab(text: context.l10n.profilePendingTab, icon: const Icon(Icons.pending_actions_outlined)),
|
|
||||||
const Tab(text: 'Alias', icon: Icon(Icons.link_outlined)),
|
|
||||||
const Tab(text: 'AI', icon: Icon(Icons.auto_awesome_outlined)),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -49,26 +69,6 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
|
|||||||
padding: EdgeInsets.fromLTRB(12, 12, 12, 8),
|
padding: EdgeInsets.fromLTRB(12, 12, 12, 8),
|
||||||
child: AdminDatabasePanel(embedded: true),
|
child: AdminDatabasePanel(embedded: true),
|
||||||
),
|
),
|
||||||
Padding(
|
|
||||||
padding: EdgeInsets.fromLTRB(12, 12, 12, 8),
|
|
||||||
child: AdminInventoryPanel(embedded: true),
|
|
||||||
),
|
|
||||||
Padding(
|
|
||||||
padding: EdgeInsets.fromLTRB(12, 12, 12, 8),
|
|
||||||
child: AdminProductsPanel(embedded: true),
|
|
||||||
),
|
|
||||||
Padding(
|
|
||||||
padding: EdgeInsets.fromLTRB(12, 12, 12, 8),
|
|
||||||
child: AdminPendingProductsPanel(embedded: true),
|
|
||||||
),
|
|
||||||
Padding(
|
|
||||||
padding: EdgeInsets.fromLTRB(12, 12, 12, 8),
|
|
||||||
child: AdminAliasesPanel(embedded: true),
|
|
||||||
),
|
|
||||||
Padding(
|
|
||||||
padding: EdgeInsets.fromLTRB(12, 12, 12, 8),
|
|
||||||
child: AdminAiPanel(embedded: true),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -254,10 +254,7 @@ class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
|
|||||||
|
|
||||||
void _showError(Object e) {
|
void _showError(Object e) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)),
|
||||||
content: Text(mapErrorToUserMessage(e, context)),
|
|
||||||
backgroundColor: Theme.of(context).colorScheme.error,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,15 +296,11 @@ class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
|
|||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
if (_error != null) {
|
if (_error != null) {
|
||||||
return Center(
|
return buildCopyableErrorPanel(
|
||||||
child: Column(
|
context: context,
|
||||||
mainAxisSize: MainAxisSize.min,
|
message: _error!,
|
||||||
children: [
|
onRetry: _load,
|
||||||
Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
|
title: 'Kunde inte läsa användare',
|
||||||
const SizedBox(height: 16),
|
|
||||||
FilledButton(onPressed: _load, child: Text(context.l10n.retryAction)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (_users.isEmpty) {
|
if (_users.isEmpty) {
|
||||||
@@ -315,14 +308,36 @@ class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
if (widget.embedded) ...[
|
if (widget.embedded) ...[
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('Användarkonton', style: theme.textTheme.titleMedium),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Här styr du konton, roller, premium och delning. När listan är tom kan du skapa den första användaren direkt.',
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: _createUser,
|
onPressed: _createUser,
|
||||||
icon: const Icon(Icons.person_add_outlined),
|
icon: const Icon(Icons.person_add_outlined),
|
||||||
label: Text(context.l10n.adminNewUser),
|
label: Text(context.l10n.adminNewUser),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
],
|
],
|
||||||
Text(context.l10n.adminNoUsers),
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Text(context.l10n.adminNoUsers),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -354,14 +369,36 @@ class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Text('Användarkonton', style: theme.textTheme.titleMedium),
|
||||||
child: Text(
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
context.l10n.adminUsersDescription,
|
context.l10n.adminUsersDescription,
|
||||||
style: theme.textTheme.bodyMedium,
|
style: theme.textTheme.bodyMedium,
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
Chip(label: Text('Roller')),
|
||||||
|
Chip(label: Text('Premium')),
|
||||||
|
Chip(label: Text('Delning')),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Spacer(),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.refresh),
|
icon: const Icon(Icons.refresh),
|
||||||
tooltip: 'Uppdatera',
|
tooltip: 'Uppdatera',
|
||||||
@@ -561,7 +598,13 @@ class _CreateUserDialogState extends State<_CreateUserDialog> {
|
|||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
Text(
|
||||||
|
'Skapa ett nytt konto med tydlig roll direkt från adminvyn. Du väljer bara uppgifter som krävs för inloggning och åtkomst.',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _usernameCtrl,
|
controller: _usernameCtrl,
|
||||||
decoration: InputDecoration(labelText: context.l10n.profileUsernameLabel),
|
decoration: InputDecoration(labelText: context.l10n.profileUsernameLabel),
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ class InventoryRepository {
|
|||||||
await _api.deleteJson(InventoryApiPaths.remove(id), token: token);
|
await _api.deleteJson(InventoryApiPaths.remove(id), token: token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> moveInventoryItemToPantry(int id, {String? token}) async {
|
||||||
|
await _api.postJson(InventoryApiPaths.moveToPantry(id), body: null, token: token);
|
||||||
|
}
|
||||||
|
|
||||||
Future<InventoryItem> consumeInventoryItem(
|
Future<InventoryItem> consumeInventoryItem(
|
||||||
int id, {
|
int id, {
|
||||||
required double amountUsed,
|
required double amountUsed,
|
||||||
|
|||||||
@@ -95,6 +95,36 @@ class InventoryScreen extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final headerSection = Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 12, 12, 4),
|
||||||
|
child: Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(context.l10n.profileInventoryTab, style: Theme.of(context).textTheme.titleMedium),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Din personliga inventarie. Här ser du sådant du faktiskt äger, kan sortera på plats och bäst före, och flytta vidare till recept eller baslager.',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
Chip(label: Text('User-scope')),
|
||||||
|
Chip(label: Text('Bäst före')),
|
||||||
|
Chip(label: Text('Swipa för +/-')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
if (visibleItems.isEmpty) {
|
if (visibleItems.isEmpty) {
|
||||||
return Stack(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
@@ -102,6 +132,7 @@ class InventoryScreen extends ConsumerWidget {
|
|||||||
key: const PageStorageKey<String>('inventory-empty-list'),
|
key: const PageStorageKey<String>('inventory-empty-list'),
|
||||||
padding: const EdgeInsets.only(bottom: 88),
|
padding: const EdgeInsets.only(bottom: 88),
|
||||||
children: [
|
children: [
|
||||||
|
headerSection,
|
||||||
filterSection,
|
filterSection,
|
||||||
EmptyStateView(title: context.l10n.inventoryEmpty),
|
EmptyStateView(title: context.l10n.inventoryEmpty),
|
||||||
],
|
],
|
||||||
@@ -124,11 +155,12 @@ class InventoryScreen extends ConsumerWidget {
|
|||||||
ListView.separated(
|
ListView.separated(
|
||||||
key: const PageStorageKey<String>('inventory-main-list'),
|
key: const PageStorageKey<String>('inventory-main-list'),
|
||||||
padding: const EdgeInsets.only(bottom: 88),
|
padding: const EdgeInsets.only(bottom: 88),
|
||||||
itemCount: visibleItems.length + 1,
|
itemCount: visibleItems.length + 2,
|
||||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
if (index == 0) return filterSection;
|
if (index == 0) return filterSection;
|
||||||
final item = visibleItems[index - 1];
|
if (index == 1) return headerSection;
|
||||||
|
final item = visibleItems[index - 2];
|
||||||
return SwipeableInventoryTile(item: item);
|
return SwipeableInventoryTile(item: item);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -328,6 +328,26 @@ class _TrailingActions extends ConsumerWidget {
|
|||||||
onPressed: () => context.push('/inventory/${item.id}/edit'),
|
onPressed: () => context.push('/inventory/${item.id}/edit'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Tooltip(
|
||||||
|
message: 'Flytta till baslager',
|
||||||
|
child: IconButton(
|
||||||
|
icon: const Icon(Icons.storefront_outlined),
|
||||||
|
onPressed: () async {
|
||||||
|
try {
|
||||||
|
final token = await ref.read(authStateProvider.future);
|
||||||
|
await ref.read(inventoryRepositoryProvider).moveInventoryItemToPantry(
|
||||||
|
item.id,
|
||||||
|
token: token,
|
||||||
|
);
|
||||||
|
ref.invalidate(inventoryProvider);
|
||||||
|
} catch (e) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
_DeleteButton(item: item),
|
_DeleteButton(item: item),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -71,4 +71,18 @@ class PantryRepository {
|
|||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> movePantryItemToInventory(
|
||||||
|
int id, {
|
||||||
|
required Map<String, dynamic> body,
|
||||||
|
String? token,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
await _api.postJson(PantryApiPaths.moveToInventory(id), body: body, token: token);
|
||||||
|
_logger.info('Moved pantry item with ID: $id to inventory');
|
||||||
|
} catch (error) {
|
||||||
|
_logger.severe('Failed to move pantry item to inventory: $error');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -40,7 +40,7 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
|
|||||||
_logger.info('Initializing PantryScreen');
|
_logger.info('Initializing PantryScreen');
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _addToInventory(PantryItem item) async {
|
Future<void> _moveToInventory(PantryItem item) async {
|
||||||
final quantityController = TextEditingController(text: '1');
|
final quantityController = TextEditingController(text: '1');
|
||||||
String selectedUnit = 'st';
|
String selectedUnit = 'st';
|
||||||
String? selectedLocation;
|
String? selectedLocation;
|
||||||
@@ -155,8 +155,9 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
final token = await ref.read(authStateProvider.future);
|
final token = await ref.read(authStateProvider.future);
|
||||||
await ref.read(inventoryRepositoryProvider).createInventoryItem(
|
await ref.read(pantryRepositoryProvider).movePantryItemToInventory(
|
||||||
{
|
item.id,
|
||||||
|
body: {
|
||||||
'productId': item.productId,
|
'productId': item.productId,
|
||||||
'quantity': payload['quantity'] as double,
|
'quantity': payload['quantity'] as double,
|
||||||
'unit': payload['unit'] as String,
|
'unit': payload['unit'] as String,
|
||||||
@@ -164,10 +165,11 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
|
|||||||
},
|
},
|
||||||
token: token,
|
token: token,
|
||||||
);
|
);
|
||||||
|
ref.invalidate(pantryProvider);
|
||||||
ref.invalidate(inventoryProvider);
|
ref.invalidate(inventoryProvider);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text(context.l10n.pantryItemAdded(item.displayName))),
|
SnackBar(content: Text('Flyttade "${item.displayName}" till inventarie.')),
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
_logger.severe('Failed to add item to inventory: $error');
|
_logger.severe('Failed to add item to inventory: $error');
|
||||||
@@ -235,12 +237,14 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
|
|||||||
if (pantryAsync.hasError || productsAsync.hasError) {
|
if (pantryAsync.hasError || productsAsync.hasError) {
|
||||||
final error = pantryAsync.error ?? productsAsync.error;
|
final error = pantryAsync.error ?? productsAsync.error;
|
||||||
_logger.severe('Error loading pantry or products: $error');
|
_logger.severe('Error loading pantry or products: $error');
|
||||||
return ErrorStateView(
|
return buildCopyableErrorPanel(
|
||||||
|
context: context,
|
||||||
message: mapErrorToUserMessage(error ?? 'Okänt fel', context),
|
message: mapErrorToUserMessage(error ?? 'Okänt fel', context),
|
||||||
onRetry: () {
|
onRetry: () {
|
||||||
ref.invalidate(pantryProvider);
|
ref.invalidate(pantryProvider);
|
||||||
ref.invalidate(pantryProductsProvider);
|
ref.invalidate(pantryProductsProvider);
|
||||||
},
|
},
|
||||||
|
title: 'Kunde inte läsa baslagret',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,11 +327,42 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final headerSection = Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 12, 12, 4),
|
||||||
|
child: Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('Baslager', style: Theme.of(context).textTheme.titleMedium),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Det här är ditt user-scope baslager. Här lagrar du sådant du vill ha lätt åtkomligt och kan flytta poster vidare till inventarie när det behövs.',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
Chip(label: Text('User-scope')),
|
||||||
|
Chip(label: Text('Flytta till inventarie')),
|
||||||
|
Chip(label: Text('Plats + kategori')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
final content = filteredItems.isEmpty
|
final content = filteredItems.isEmpty
|
||||||
? ListView(
|
? ListView(
|
||||||
key: const PageStorageKey<String>('pantry-empty-list'),
|
key: const PageStorageKey<String>('pantry-empty-list'),
|
||||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 96),
|
padding: const EdgeInsets.fromLTRB(12, 0, 12, 96),
|
||||||
children: [
|
children: [
|
||||||
|
headerSection,
|
||||||
filterSection,
|
filterSection,
|
||||||
const EmptyStateView(
|
const EmptyStateView(
|
||||||
title: 'Baslagret är tomt',
|
title: 'Baslagret är tomt',
|
||||||
@@ -338,11 +373,12 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
|
|||||||
: ListView.separated(
|
: ListView.separated(
|
||||||
key: const PageStorageKey<String>('pantry-main-list'),
|
key: const PageStorageKey<String>('pantry-main-list'),
|
||||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 96),
|
padding: const EdgeInsets.fromLTRB(12, 0, 12, 96),
|
||||||
itemCount: filteredItems.length + 1,
|
itemCount: filteredItems.length + 2,
|
||||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
if (index == 0) return filterSection;
|
if (index == 0) return filterSection;
|
||||||
final item = filteredItems[index - 1];
|
if (index == 1) return headerSection;
|
||||||
|
final item = filteredItems[index - 2];
|
||||||
final l1Category = _resolveL1Category(item, productById);
|
final l1Category = _resolveL1Category(item, productById);
|
||||||
|
|
||||||
return ListTile(
|
return ListTile(
|
||||||
@@ -358,9 +394,9 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Lägg i inventarie',
|
tooltip: 'Flytta till inventarie',
|
||||||
icon: const Icon(Icons.inventory_2_outlined),
|
icon: const Icon(Icons.inventory_2_outlined),
|
||||||
onPressed: () => _addToInventory(item),
|
onPressed: () => _moveToInventory(item),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Ta bort',
|
tooltip: 'Ta bort',
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ import '../data/profile_repository.dart';
|
|||||||
import '../domain/user_profile.dart';
|
import '../domain/user_profile.dart';
|
||||||
import 'user_aliases_screen.dart';
|
import 'user_aliases_screen.dart';
|
||||||
|
|
||||||
enum _ProfileTab { profile }
|
|
||||||
|
|
||||||
class ProfileScreen extends ConsumerStatefulWidget {
|
class ProfileScreen extends ConsumerStatefulWidget {
|
||||||
const ProfileScreen({super.key});
|
const ProfileScreen({super.key});
|
||||||
|
|
||||||
@@ -24,7 +22,6 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
|||||||
bool _isSaving = false;
|
bool _isSaving = false;
|
||||||
String? _error;
|
String? _error;
|
||||||
UserProfile? _profile;
|
UserProfile? _profile;
|
||||||
_ProfileTab _activeTab = _ProfileTab.profile;
|
|
||||||
|
|
||||||
late final TextEditingController _emailCtrl;
|
late final TextEditingController _emailCtrl;
|
||||||
late final TextEditingController _firstNameCtrl;
|
late final TextEditingController _firstNameCtrl;
|
||||||
@@ -99,40 +96,6 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
|||||||
context.go('/login');
|
context.go('/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
List<_ProfileTab> _visibleTabs(bool isAdmin) {
|
|
||||||
return [
|
|
||||||
_ProfileTab.profile,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
String _tabLabel(_ProfileTab tab) {
|
|
||||||
switch (tab) {
|
|
||||||
case _ProfileTab.profile:
|
|
||||||
return context.l10n.profileMyProfileTab;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildTabBar(BuildContext context, List<_ProfileTab> tabs) {
|
|
||||||
return SingleChildScrollView(
|
|
||||||
scrollDirection: Axis.horizontal,
|
|
||||||
child: Row(
|
|
||||||
children: tabs
|
|
||||||
.map(
|
|
||||||
(tab) => Padding(
|
|
||||||
padding: const EdgeInsets.only(right: 8),
|
|
||||||
child: ChoiceChip(
|
|
||||||
label: Text(_tabLabel(tab)),
|
|
||||||
selected: _activeTab == tab,
|
|
||||||
onSelected: (_) => setState(() => _activeTab = tab),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toList(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildProfileForm(BuildContext context, ThemeData theme) {
|
Widget _buildProfileForm(BuildContext context, ThemeData theme) {
|
||||||
return Form(
|
return Form(
|
||||||
key: _formKey,
|
key: _formKey,
|
||||||
@@ -182,15 +145,16 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
|||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: FilledButton(
|
child: FilledButton.icon(
|
||||||
onPressed: _isSaving ? null : _save,
|
onPressed: _isSaving ? null : _save,
|
||||||
child: _isSaving
|
icon: _isSaving
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
height: 20,
|
height: 20,
|
||||||
width: 20,
|
width: 20,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
)
|
)
|
||||||
: Text(context.l10n.profileSaveAction),
|
: const Icon(Icons.save_outlined),
|
||||||
|
label: Text(context.l10n.profileSaveAction),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -198,50 +162,37 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildActiveTabContent(BuildContext context, ThemeData theme) {
|
|
||||||
switch (_activeTab) {
|
|
||||||
case _ProfileTab.profile:
|
|
||||||
return _buildProfileForm(context, theme);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final tabs = _visibleTabs(_profile?.isAdmin == true);
|
|
||||||
if (!tabs.contains(_activeTab)) {
|
|
||||||
_activeTab = _ProfileTab.profile;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_isLoading) {
|
if (_isLoading) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_error != null) {
|
if (_error != null) {
|
||||||
return Center(
|
return buildCopyableErrorPanel(
|
||||||
child: Column(
|
context: context,
|
||||||
mainAxisSize: MainAxisSize.min,
|
message: _error!,
|
||||||
children: [
|
onRetry: _loadProfile,
|
||||||
Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
|
title: 'Kunde inte läsa profilen',
|
||||||
const SizedBox(height: 16),
|
|
||||||
FilledButton(onPressed: _loadProfile, child: Text(context.l10n.retryAction)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final profile = _profile!;
|
||||||
|
|
||||||
return ListView(
|
return ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
CircleAvatar(
|
CircleAvatar(
|
||||||
radius: 28,
|
radius: 28,
|
||||||
child: Text(
|
child: Text(
|
||||||
(_profile?.username.isNotEmpty == true
|
(profile.username.isNotEmpty ? profile.username[0] : '?').toUpperCase(),
|
||||||
? _profile!.username[0]
|
|
||||||
: '?')
|
|
||||||
.toUpperCase(),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
@@ -249,21 +200,27 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
Text(profile.username, style: theme.textTheme.titleLarge),
|
||||||
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
_profile?.username ?? '',
|
profile.email,
|
||||||
style: theme.textTheme.titleLarge,
|
|
||||||
),
|
|
||||||
if ((_profile?.email ?? '').isNotEmpty)
|
|
||||||
Text(
|
|
||||||
_profile!.email,
|
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if ((profile.firstName ?? '').isNotEmpty || (profile.lastName ?? '').isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
[profile.firstName, profile.lastName]
|
||||||
|
.where((part) => part != null && part.trim().isNotEmpty)
|
||||||
|
.join(' '),
|
||||||
|
style: theme.textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (_profile?.isAdmin == true)
|
if (profile.isAdmin)
|
||||||
Chip(
|
Chip(
|
||||||
label: const Text('Admin'),
|
label: const Text('Admin'),
|
||||||
avatar: const Icon(Icons.shield_outlined, size: 16),
|
avatar: const Icon(Icons.shield_outlined, size: 16),
|
||||||
@@ -272,23 +229,55 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
),
|
||||||
_buildTabBar(context, tabs),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 12),
|
||||||
_buildActiveTabContent(context, theme),
|
Card(
|
||||||
const SizedBox(height: 24),
|
child: Padding(
|
||||||
ListTile(
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('Min profil', style: theme.textTheme.titleMedium),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Här uppdaterar du kontaktuppgifter och ditt namn. Alias och importrelaterad data finns i en separat vy.',
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_buildProfileForm(context, theme),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Card(
|
||||||
|
child: ListTile(
|
||||||
leading: const Icon(Icons.link_outlined),
|
leading: const Icon(Icons.link_outlined),
|
||||||
title: const Text('Mina kvittoalias'),
|
title: const Text('Mina kvittoalias'),
|
||||||
subtitle: const Text('Visa och hantera sparade alias från kvittoimport'),
|
subtitle: const Text(
|
||||||
|
'Visa privata alias och globala fallback-alias som används i receipt-importen.',
|
||||||
|
),
|
||||||
trailing: const Icon(Icons.chevron_right),
|
trailing: const Icon(Icons.chevron_right),
|
||||||
onTap: () => Navigator.of(context).push(
|
onTap: () => Navigator.of(context).push(
|
||||||
MaterialPageRoute(builder: (_) => const UserAliasesScreen()),
|
MaterialPageRoute(builder: (_) => const UserAliasesScreen()),
|
||||||
),
|
),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
|
||||||
tileColor: Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: 0.4),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('Snabbåtgärder', style: theme.textTheme.titleMedium),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Logga ut eller gå vidare till aliasvyn när du behöver granska importmatchningar.',
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: OutlinedButton.icon(
|
child: OutlinedButton.icon(
|
||||||
@@ -298,6 +287,10 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../../core/api/api_error_mapper.dart';
|
||||||
import '../../admin/data/admin_repository.dart';
|
import '../../admin/data/admin_repository.dart';
|
||||||
import '../../admin/domain/receipt_alias.dart';
|
import '../../admin/domain/receipt_alias.dart';
|
||||||
|
|
||||||
@@ -39,7 +40,7 @@ class _UserAliasesScreenState extends ConsumerState<UserAliasesScreen> {
|
|||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _error = 'Kunde inte ladda alias: $e');
|
setState(() => _error = mapErrorToUserMessage(e, context));
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _isLoading = false);
|
if (mounted) setState(() => _isLoading = false);
|
||||||
}
|
}
|
||||||
@@ -95,19 +96,45 @@ class _UserAliasesScreenState extends ConsumerState<UserAliasesScreen> {
|
|||||||
body: Builder(builder: (_) {
|
body: Builder(builder: (_) {
|
||||||
if (_isLoading) return const Center(child: CircularProgressIndicator());
|
if (_isLoading) return const Center(child: CircularProgressIndicator());
|
||||||
if (_error != null) {
|
if (_error != null) {
|
||||||
return Center(
|
return buildCopyableErrorPanel(
|
||||||
child: Column(
|
context: context,
|
||||||
mainAxisSize: MainAxisSize.min,
|
message: _error!,
|
||||||
children: [
|
onRetry: _load,
|
||||||
Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
|
title: 'Kunde inte läsa alias',
|
||||||
const SizedBox(height: 12),
|
|
||||||
FilledButton(onPressed: _load, child: const Text('Försök igen')),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (_aliases.isEmpty) {
|
if (_aliases.isEmpty) {
|
||||||
return Center(
|
return ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('Aliasöversikt', style: theme.textTheme.titleMedium),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Privata alias gäller bara dig. Globala alias används som fallback i receipt-importen och skapas av admin.',
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
Chip(label: Text('Privat alias')),
|
||||||
|
Chip(label: Text('Global fallback')),
|
||||||
|
Chip(label: Text('Receipt-import')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(32),
|
padding: const EdgeInsets.all(32),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -129,10 +156,43 @@ class _UserAliasesScreenState extends ConsumerState<UserAliasesScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return ListView.separated(
|
return ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('Aliasöversikt', style: theme.textTheme.titleMedium),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Privata alias gäller bara dig. Globala alias används som fallback i receipt-importen och skapas av admin.',
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
Chip(label: Text('Privat alias')),
|
||||||
|
Chip(label: Text('Global fallback')),
|
||||||
|
Chip(label: Text('Receipt-import')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ListView.separated(
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
itemCount: _aliases.length,
|
itemCount: _aliases.length,
|
||||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||||
itemBuilder: (ctx, i) {
|
itemBuilder: (ctx, i) {
|
||||||
@@ -176,6 +236,8 @@ class _UserAliasesScreenState extends ConsumerState<UserAliasesScreen> {
|
|||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user