feat: implement update functionality for receipt aliases and add corresponding tests
This commit is contained in:
@@ -0,0 +1,12 @@
|
|||||||
|
import { IsInt, IsOptional, IsString, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateReceiptAliasDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
receiptName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
productId?: number;
|
||||||
|
}
|
||||||
@@ -1,6 +1,16 @@
|
|||||||
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Post } from '@nestjs/common';
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseIntPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { ReceiptAliasService } from './receipt-alias.service';
|
import { ReceiptAliasService } from './receipt-alias.service';
|
||||||
import { CreateReceiptAliasDto } from './dto/create-receipt-alias.dto';
|
import { CreateReceiptAliasDto } from './dto/create-receipt-alias.dto';
|
||||||
|
import { UpdateReceiptAliasDto } from './dto/update-receipt-alias.dto';
|
||||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
|
|
||||||
@Controller('receipt-aliases')
|
@Controller('receipt-aliases')
|
||||||
@@ -20,6 +30,15 @@ export class ReceiptAliasController {
|
|||||||
return this.receiptAliasService.upsert(dto, user.userId, user.role);
|
return this.receiptAliasService.upsert(dto, user.userId, user.role);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
update(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
@Body() dto: UpdateReceiptAliasDto,
|
||||||
|
@CurrentUser() user: { userId: number; role: string },
|
||||||
|
) {
|
||||||
|
return this.receiptAliasService.update(id, dto, user.userId, user.role);
|
||||||
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
remove(
|
remove(
|
||||||
@Param('id', ParseIntPipe) id: number,
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ describe('ReceiptAlias controller security', () => {
|
|||||||
const receiptAliasServiceMock = {
|
const receiptAliasServiceMock = {
|
||||||
findAllForUser: jest.fn(),
|
findAllForUser: jest.fn(),
|
||||||
upsert: jest.fn(),
|
upsert: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
remove: jest.fn(),
|
remove: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -37,4 +38,13 @@ describe('ReceiptAlias controller security', () => {
|
|||||||
|
|
||||||
expect(receiptAliasServiceMock.remove).toHaveBeenCalledWith(10, 42, 'user');
|
expect(receiptAliasServiceMock.remove).toHaveBeenCalledWith(10, 42, 'user');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('update scopear till @CurrentUser userId + role', () => {
|
||||||
|
const dto = { receiptName: 'Arla mjolk 1l', productId: 7 };
|
||||||
|
receiptAliasServiceMock.update.mockResolvedValue({ id: 10 });
|
||||||
|
|
||||||
|
controller.update(10, dto as any, { userId: 42, role: 'user' });
|
||||||
|
|
||||||
|
expect(receiptAliasServiceMock.update).toHaveBeenCalledWith(10, dto, 42, 'user');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -64,4 +64,65 @@ describe('ReceiptAliasService', () => {
|
|||||||
|
|
||||||
await expect(service.remove(99, 10, 'admin')).rejects.toBeInstanceOf(NotFoundException);
|
await expect(service.remove(99, 10, 'admin')).rejects.toBeInstanceOf(NotFoundException);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uppdaterar alias säkert via update()', async () => {
|
||||||
|
prismaMock.receiptAlias.findUnique.mockResolvedValue({
|
||||||
|
id: 12,
|
||||||
|
receiptName: 'mjolk 1l',
|
||||||
|
productId: 7,
|
||||||
|
ownerId: 10,
|
||||||
|
isGlobal: false,
|
||||||
|
});
|
||||||
|
prismaMock.receiptAlias.findFirst.mockResolvedValue(null);
|
||||||
|
prismaMock.receiptAlias.update.mockResolvedValue({ id: 12 });
|
||||||
|
|
||||||
|
await service.update(
|
||||||
|
12,
|
||||||
|
{ receiptName: ' ARLA MJOLK 1L ', productId: 8 },
|
||||||
|
10,
|
||||||
|
'user',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(prismaMock.receiptAlias.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 12 },
|
||||||
|
data: {
|
||||||
|
receiptName: 'arla mjolk 1l',
|
||||||
|
productId: 8,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blockerar update när aliasnamn krockar i samma scope', async () => {
|
||||||
|
prismaMock.receiptAlias.findUnique.mockResolvedValue({
|
||||||
|
id: 12,
|
||||||
|
receiptName: 'mjolk 1l',
|
||||||
|
productId: 7,
|
||||||
|
ownerId: 10,
|
||||||
|
isGlobal: false,
|
||||||
|
});
|
||||||
|
prismaMock.receiptAlias.findFirst.mockResolvedValue({
|
||||||
|
id: 99,
|
||||||
|
receiptName: 'arla mjolk 1l',
|
||||||
|
ownerId: 10,
|
||||||
|
isGlobal: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.update(12, { receiptName: 'ARLA MJOLK 1L' }, 10, 'user'),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blockerar update för obehörig användare', async () => {
|
||||||
|
prismaMock.receiptAlias.findUnique.mockResolvedValue({
|
||||||
|
id: 12,
|
||||||
|
receiptName: 'mjolk 1l',
|
||||||
|
productId: 7,
|
||||||
|
ownerId: null,
|
||||||
|
isGlobal: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.update(12, { receiptName: 'mjolk 1 liter' }, 10, 'user'),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { CreateReceiptAliasDto } from './dto/create-receipt-alias.dto';
|
import { CreateReceiptAliasDto } from './dto/create-receipt-alias.dto';
|
||||||
|
import { UpdateReceiptAliasDto } from './dto/update-receipt-alias.dto';
|
||||||
import {
|
import {
|
||||||
normalizeReceiptAliasName,
|
normalizeReceiptAliasName,
|
||||||
validateReceiptAliasName,
|
validateReceiptAliasName,
|
||||||
@@ -93,4 +94,60 @@ export class ReceiptAliasService {
|
|||||||
|
|
||||||
return this.prisma.receiptAlias.delete({ where: { id } });
|
return this.prisma.receiptAlias.delete({ where: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
id: number,
|
||||||
|
dto: UpdateReceiptAliasDto,
|
||||||
|
userId: number,
|
||||||
|
role: string,
|
||||||
|
) {
|
||||||
|
const alias = await this.prisma.receiptAlias.findUnique({ where: { id } });
|
||||||
|
if (!alias) {
|
||||||
|
throw new NotFoundException(`Aliaspost med id ${id} hittades inte`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const canEdit =
|
||||||
|
role === 'admin' ||
|
||||||
|
(alias.ownerId === userId && alias.isGlobal === false);
|
||||||
|
|
||||||
|
if (!canEdit) {
|
||||||
|
throw new ForbiddenException('Du har inte behörighet att redigera aliaset');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.receiptName == null && dto.productId == null) {
|
||||||
|
throw new BadRequestException('Inget att uppdatera. Ange receiptName eller productId.');
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextReceiptName = alias.receiptName;
|
||||||
|
if (dto.receiptName != null) {
|
||||||
|
nextReceiptName = normalizeReceiptAliasName(dto.receiptName);
|
||||||
|
const validationError = validateReceiptAliasName(nextReceiptName);
|
||||||
|
if (validationError) {
|
||||||
|
throw new BadRequestException(validationError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextProductId = dto.productId ?? alias.productId;
|
||||||
|
|
||||||
|
// Skydda mot krock i samma scope när receiptName ändras.
|
||||||
|
if (nextReceiptName !== alias.receiptName) {
|
||||||
|
const conflict = await this.prisma.receiptAlias.findFirst({
|
||||||
|
where: alias.isGlobal
|
||||||
|
? { receiptName: nextReceiptName, isGlobal: true }
|
||||||
|
: { receiptName: nextReceiptName, ownerId: alias.ownerId, isGlobal: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (conflict && conflict.id !== alias.id) {
|
||||||
|
throw new BadRequestException('Aliasnamnet finns redan i samma scope.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.receiptAlias.update({
|
||||||
|
where: { id: alias.id },
|
||||||
|
data: {
|
||||||
|
receiptName: nextReceiptName,
|
||||||
|
productId: nextProductId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ class ReceiptImportApiPaths {
|
|||||||
|
|
||||||
class ReceiptAliasApiPaths {
|
class ReceiptAliasApiPaths {
|
||||||
static const list = '/receipt-aliases';
|
static const list = '/receipt-aliases';
|
||||||
|
static String update(int id) => '/receipt-aliases/$id';
|
||||||
static String remove(int id) => '/receipt-aliases/$id';
|
static String remove(int id) => '/receipt-aliases/$id';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -420,6 +420,19 @@ class AdminRepository {
|
|||||||
'isGlobal': isGlobal,
|
'isGlobal': isGlobal,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Future<void> updateReceiptAlias(
|
||||||
|
int id, {
|
||||||
|
String? receiptName,
|
||||||
|
int? productId,
|
||||||
|
}) {
|
||||||
|
final body = <String, dynamic>{
|
||||||
|
if (receiptName != null) 'receiptName': receiptName,
|
||||||
|
if (productId != null) 'productId': productId,
|
||||||
|
};
|
||||||
|
|
||||||
|
return _patchVoid(ReceiptAliasApiPaths.update(id), body);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> removeReceiptAlias(int id) =>
|
Future<void> removeReceiptAlias(int id) =>
|
||||||
_deleteVoid(ReceiptAliasApiPaths.remove(id));
|
_deleteVoid(ReceiptAliasApiPaths.remove(id));
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class _AdminAliasesPanelState extends ConsumerState<AdminAliasesPanel> {
|
|||||||
|
|
||||||
final TextEditingController _aliasController = TextEditingController();
|
final TextEditingController _aliasController = TextEditingController();
|
||||||
int? _selectedProductId;
|
int? _selectedProductId;
|
||||||
|
int? _editingAliasId;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -79,18 +80,32 @@ class _AdminAliasesPanelState extends ConsumerState<AdminAliasesPanel> {
|
|||||||
|
|
||||||
setState(() => _isSaving = true);
|
setState(() => _isSaving = true);
|
||||||
try {
|
try {
|
||||||
await ref.read(adminRepositoryProvider).upsertReceiptAlias(
|
final repo = ref.read(adminRepositoryProvider);
|
||||||
receiptName: rawAlias,
|
final isEditing = _editingAliasId != null;
|
||||||
productId: productId,
|
if (isEditing) {
|
||||||
isGlobal: true,
|
await repo.updateReceiptAlias(
|
||||||
);
|
_editingAliasId!,
|
||||||
|
receiptName: rawAlias,
|
||||||
|
productId: productId,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await repo.upsertReceiptAlias(
|
||||||
|
receiptName: rawAlias,
|
||||||
|
productId: productId,
|
||||||
|
isGlobal: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
_aliasController.clear();
|
_aliasController.clear();
|
||||||
setState(() => _selectedProductId = null);
|
setState(() {
|
||||||
|
_selectedProductId = null;
|
||||||
|
_editingAliasId = null;
|
||||||
|
});
|
||||||
await _load();
|
await _load();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('Alias sparad.')),
|
SnackBar(content: Text(isEditing ? 'Alias uppdaterat.' : 'Alias sparad.')),
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -102,6 +117,29 @@ class _AdminAliasesPanelState extends ConsumerState<AdminAliasesPanel> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _startEditAlias(ReceiptAlias alias) {
|
||||||
|
if (!alias.isGlobal) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Privata alias redigeras av respektive användare.')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_editingAliasId = alias.id;
|
||||||
|
_aliasController.text = alias.receiptName;
|
||||||
|
_selectedProductId = alias.productId;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _cancelEditAlias() {
|
||||||
|
setState(() {
|
||||||
|
_editingAliasId = null;
|
||||||
|
_aliasController.clear();
|
||||||
|
_selectedProductId = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _removeAlias(ReceiptAlias alias) async {
|
Future<void> _removeAlias(ReceiptAlias alias) async {
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -202,11 +240,23 @@ class _AdminAliasesPanelState extends ConsumerState<AdminAliasesPanel> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
trailing: IconButton(
|
trailing: Row(
|
||||||
onPressed: () => _removeAlias(alias),
|
mainAxisSize: MainAxisSize.min,
|
||||||
icon: const Icon(Icons.delete_outline),
|
children: [
|
||||||
tooltip: 'Ta bort alias',
|
IconButton(
|
||||||
color: Theme.of(context).colorScheme.error,
|
onPressed: () => _startEditAlias(alias),
|
||||||
|
icon: const Icon(Icons.edit_outlined),
|
||||||
|
tooltip: alias.isGlobal
|
||||||
|
? 'Redigera alias'
|
||||||
|
: 'Privata alias redigeras av användaren',
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => _removeAlias(alias),
|
||||||
|
icon: const Icon(Icons.delete_outline),
|
||||||
|
tooltip: 'Ta bort alias',
|
||||||
|
color: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -256,6 +306,7 @@ class _AdminAliasesPanelState extends ConsumerState<AdminAliasesPanel> {
|
|||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: DropdownButtonFormField<int>(
|
child: DropdownButtonFormField<int>(
|
||||||
|
key: ValueKey<int?>(_selectedProductId),
|
||||||
initialValue: _selectedProductId,
|
initialValue: _selectedProductId,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Produkt',
|
labelText: 'Produkt',
|
||||||
@@ -275,6 +326,13 @@ class _AdminAliasesPanelState extends ConsumerState<AdminAliasesPanel> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
|
if (_editingAliasId != null) ...[
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: _isSaving ? null : _cancelEditAlias,
|
||||||
|
child: const Text('Avbryt'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
],
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: _isSaving ? null : _upsertAlias,
|
onPressed: _isSaving ? null : _upsertAlias,
|
||||||
icon: _isSaving
|
icon: _isSaving
|
||||||
@@ -283,8 +341,8 @@ class _AdminAliasesPanelState extends ConsumerState<AdminAliasesPanel> {
|
|||||||
height: 14,
|
height: 14,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
)
|
)
|
||||||
: const Icon(Icons.save_outlined),
|
: Icon(_editingAliasId != null ? Icons.edit_outlined : Icons.save_outlined),
|
||||||
label: const Text('Spara'),
|
label: Text(_editingAliasId != null ? 'Uppdatera' : 'Spara'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -498,6 +498,74 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
setState(() {});
|
setState(() {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _editAliasForItem(int index) async {
|
||||||
|
final items = _items;
|
||||||
|
if (items == null || index < 0 || index >= items.length) return;
|
||||||
|
|
||||||
|
final item = items[index];
|
||||||
|
final edit = _edits[index];
|
||||||
|
final productId = edit?.productId;
|
||||||
|
final initialAlias = item.rawName.trim();
|
||||||
|
|
||||||
|
if (productId == null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Välj produkt för raden innan du redigerar alias.')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final controller = TextEditingController(text: initialAlias);
|
||||||
|
final aliasName = await showDialog<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Spara alias för raden'),
|
||||||
|
content: TextField(
|
||||||
|
controller: controller,
|
||||||
|
autofocus: true,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Aliasnamn',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
onSubmitted: (value) => Navigator.pop(ctx, value.trim()),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx),
|
||||||
|
child: const Text('Avbryt'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, controller.text.trim()),
|
||||||
|
child: const Text('Spara'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
controller.dispose();
|
||||||
|
|
||||||
|
if (!mounted || aliasName == null) return;
|
||||||
|
if (aliasName.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Aliasnamn kan inte vara tomt.')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ref.read(adminRepositoryProvider).upsertReceiptAlias(
|
||||||
|
receiptName: aliasName,
|
||||||
|
productId: productId,
|
||||||
|
isGlobal: false,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Alias sparat.')),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
showGlobalErrorDialog(context, 'Kunde inte uppdatera alias: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _addSelected() async {
|
Future<void> _addSelected() async {
|
||||||
final items = _items;
|
final items = _items;
|
||||||
if (items == null) return;
|
if (items == null) return;
|
||||||
@@ -844,6 +912,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
i,
|
i,
|
||||||
initialEntryMode: ImportProductEntryMode.create,
|
initialEntryMode: ImportProductEntryMode.create,
|
||||||
),
|
),
|
||||||
|
onAliasEditRequested: () => _editAliasForItem(i),
|
||||||
onDeleteRequested: () => _deleteItem(i),
|
onDeleteRequested: () => _deleteItem(i),
|
||||||
matchedViaBadgeBuilder: _buildMatchedViaBadge,
|
matchedViaBadgeBuilder: _buildMatchedViaBadge,
|
||||||
);
|
);
|
||||||
@@ -877,6 +946,7 @@ class _ReceiptImportResultRow extends ConsumerWidget {
|
|||||||
final VoidCallback onEditRequested;
|
final VoidCallback onEditRequested;
|
||||||
final VoidCallback onSelectExistingRequested;
|
final VoidCallback onSelectExistingRequested;
|
||||||
final VoidCallback onCreateRequested;
|
final VoidCallback onCreateRequested;
|
||||||
|
final VoidCallback onAliasEditRequested;
|
||||||
final VoidCallback onDeleteRequested;
|
final VoidCallback onDeleteRequested;
|
||||||
final Widget Function(ParsedReceiptItem item, ThemeData theme)
|
final Widget Function(ParsedReceiptItem item, ThemeData theme)
|
||||||
matchedViaBadgeBuilder;
|
matchedViaBadgeBuilder;
|
||||||
@@ -891,6 +961,7 @@ class _ReceiptImportResultRow extends ConsumerWidget {
|
|||||||
required this.onEditRequested,
|
required this.onEditRequested,
|
||||||
required this.onSelectExistingRequested,
|
required this.onSelectExistingRequested,
|
||||||
required this.onCreateRequested,
|
required this.onCreateRequested,
|
||||||
|
required this.onAliasEditRequested,
|
||||||
required this.onDeleteRequested,
|
required this.onDeleteRequested,
|
||||||
required this.matchedViaBadgeBuilder,
|
required this.matchedViaBadgeBuilder,
|
||||||
});
|
});
|
||||||
@@ -1088,7 +1159,7 @@ class _ReceiptImportResultRow extends ConsumerWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
trailing: SizedBox(
|
trailing: SizedBox(
|
||||||
width: 80,
|
width: 120,
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
@@ -1101,6 +1172,14 @@ class _ReceiptImportResultRow extends ConsumerWidget {
|
|||||||
: (isSuggested ? Colors.orange : theme.colorScheme.tertiary),
|
: (isSuggested ? Colors.orange : theme.colorScheme.tertiary),
|
||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
|
if (hasProduct)
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.drive_file_rename_outline, size: 18),
|
||||||
|
onPressed: onAliasEditRequested,
|
||||||
|
tooltip: 'Spara alias',
|
||||||
|
constraints: const BoxConstraints(minWidth: 40, minHeight: 40),
|
||||||
|
padding: const EdgeInsets.all(4),
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(Icons.delete_outline, size: 18, color: theme.colorScheme.error),
|
icon: Icon(Icons.delete_outline, size: 18, color: theme.colorScheme.error),
|
||||||
onPressed: onDeleteRequested,
|
onPressed: onDeleteRequested,
|
||||||
|
|||||||
@@ -93,6 +93,68 @@ class _UserAliasesScreenState extends ConsumerState<UserAliasesScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _editAlias(ReceiptAlias alias) async {
|
||||||
|
if (!alias.isPrivate) return;
|
||||||
|
|
||||||
|
final controller = TextEditingController(text: alias.receiptName);
|
||||||
|
final newAliasName = await showDialog<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Redigera alias'),
|
||||||
|
content: TextField(
|
||||||
|
controller: controller,
|
||||||
|
autofocus: true,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Kvittonamn (alias)',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
onSubmitted: (value) => Navigator.pop(ctx, value.trim()),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx),
|
||||||
|
child: const Text('Avbryt'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, controller.text.trim()),
|
||||||
|
child: const Text('Spara'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
controller.dispose();
|
||||||
|
|
||||||
|
if (!mounted || newAliasName == null) return;
|
||||||
|
final normalizedNew = newAliasName.trim();
|
||||||
|
if (normalizedNew.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Aliasnamn kan inte vara tomt.')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (normalizedNew == alias.receiptName.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final repo = ref.read(adminRepositoryProvider);
|
||||||
|
await repo.updateReceiptAlias(
|
||||||
|
alias.id,
|
||||||
|
receiptName: normalizedNew,
|
||||||
|
productId: alias.productId,
|
||||||
|
);
|
||||||
|
await _load();
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Alias uppdaterat.')),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Kunde inte uppdatera alias: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
@@ -242,11 +304,21 @@ class _UserAliasesScreenState extends ConsumerState<UserAliasesScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
trailing: alias.isPrivate
|
trailing: alias.isPrivate
|
||||||
? IconButton(
|
? Row(
|
||||||
icon: const Icon(Icons.delete_outline),
|
mainAxisSize: MainAxisSize.min,
|
||||||
tooltip: 'Ta bort alias',
|
children: [
|
||||||
color: theme.colorScheme.error,
|
IconButton(
|
||||||
onPressed: () => _delete(alias),
|
icon: const Icon(Icons.edit_outlined),
|
||||||
|
tooltip: 'Redigera alias',
|
||||||
|
onPressed: () => _editAlias(alias),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.delete_outline),
|
||||||
|
tooltip: 'Ta bort alias',
|
||||||
|
color: theme.colorScheme.error,
|
||||||
|
onPressed: () => _delete(alias),
|
||||||
|
),
|
||||||
|
],
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user