feat: add TypeScript definitions for next-auth session with accessToken and user details
Test Suite / test (24.15.0) (push) Has been cancelled
Test Suite / test (24.15.0) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, FormEvent } from 'react';
|
||||
|
||||
type Profile = {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
};
|
||||
|
||||
export default function ProfileClient() {
|
||||
const [profile, setProfile] = useState<Profile | null>(null);
|
||||
const [form, setForm] = useState({ firstName: '', lastName: '', email: '' });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/profile')
|
||||
.then((r) => r.json())
|
||||
.then((data: Profile) => {
|
||||
setProfile(data);
|
||||
setForm({
|
||||
firstName: data.firstName ?? '',
|
||||
lastName: data.lastName ?? '',
|
||||
email: data.email,
|
||||
});
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setSuccess(false);
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch('/api/profile', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
firstName: form.firstName || null,
|
||||
lastName: form.lastName || null,
|
||||
email: form.email,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setError(data.message ?? 'Kunde inte spara ändringar');
|
||||
} else {
|
||||
setSuccess(true);
|
||||
}
|
||||
} catch {
|
||||
setError('Något gick fel');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid #ddd',
|
||||
fontSize: '1rem',
|
||||
boxSizing: 'border-box',
|
||||
};
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
display: 'block',
|
||||
marginBottom: 6,
|
||||
fontWeight: 500,
|
||||
fontSize: '0.9rem',
|
||||
color: '#444',
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <p style={{ color: '#666' }}>Laddar profil...</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 480 }}>
|
||||
{/* Initialer/avatar */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '2rem' }}>
|
||||
<div
|
||||
style={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: '50%',
|
||||
background: '#2563eb',
|
||||
color: 'white',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '1.5rem',
|
||||
fontWeight: 700,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{profile?.username?.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: '1.1rem' }}>{profile?.username}</div>
|
||||
<div style={{ color: '#666', fontSize: '0.9rem' }}>{profile?.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
||||
<div>
|
||||
<label htmlFor="firstName" style={labelStyle}>Förnamn</label>
|
||||
<input
|
||||
id="firstName"
|
||||
type="text"
|
||||
value={form.firstName}
|
||||
onChange={(e) => setForm((f) => ({ ...f, firstName: e.target.value }))}
|
||||
style={inputStyle}
|
||||
maxLength={100}
|
||||
autoComplete="given-name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="lastName" style={labelStyle}>Efternamn</label>
|
||||
<input
|
||||
id="lastName"
|
||||
type="text"
|
||||
value={form.lastName}
|
||||
onChange={(e) => setForm((f) => ({ ...f, lastName: e.target.value }))}
|
||||
style={inputStyle}
|
||||
maxLength={100}
|
||||
autoComplete="family-name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" style={labelStyle}>E-post</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))}
|
||||
required
|
||||
style={inputStyle}
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>Användarnamn</label>
|
||||
<input
|
||||
type="text"
|
||||
value={profile?.username ?? ''}
|
||||
disabled
|
||||
style={{ ...inputStyle, background: '#f5f5f5', color: '#888', cursor: 'not-allowed' }}
|
||||
/>
|
||||
<p style={{ fontSize: '0.8rem', color: '#999', margin: '4px 0 0' }}>
|
||||
Användarnamn kan inte ändras
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && <p style={{ color: '#dc2626', margin: 0 }}>{error}</p>}
|
||||
{success && <p style={{ color: '#16a34a', margin: 0 }}>Ändringarna sparades!</p>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
style={{
|
||||
padding: '10px',
|
||||
background: '#2563eb',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: 6,
|
||||
fontSize: '1rem',
|
||||
cursor: saving ? 'not-allowed' : 'pointer',
|
||||
opacity: saving ? 0.7 : 1,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{saving ? 'Sparar...' : 'Spara ändringar'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
type Tab = { id: string; label: string };
|
||||
|
||||
const USER_TABS: Tab[] = [
|
||||
{ id: 'profil', label: 'Min profil' },
|
||||
{ id: 'databas', label: '🗄️ Databas' },
|
||||
];
|
||||
const ADMIN_TABS: Tab[] = [
|
||||
{ id: 'profil', label: 'Min profil' },
|
||||
{ id: 'anvandare', label: '👥 Användare' },
|
||||
{ id: 'databas', label: '🗄️ Databas' },
|
||||
{ id: 'forslag', label: '⏳ Förslag' },
|
||||
{ id: 'ai', label: '🤖 AI' },
|
||||
];
|
||||
|
||||
type Props = {
|
||||
activeTab: string;
|
||||
isAdmin: boolean;
|
||||
};
|
||||
|
||||
export default function ProfileTabs({ activeTab, isAdmin }: Props) {
|
||||
const tabs = isAdmin ? ADMIN_TABS : USER_TABS;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '0.25rem',
|
||||
borderBottom: '2px solid #e5e7eb',
|
||||
marginBottom: '2rem',
|
||||
}}
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const active = tab.id === activeTab;
|
||||
return (
|
||||
<Link
|
||||
key={tab.id}
|
||||
href={`/profil?tab=${tab.id}`}
|
||||
style={{
|
||||
padding: '0.6rem 1.2rem',
|
||||
textDecoration: 'none',
|
||||
fontWeight: active ? 600 : 400,
|
||||
fontSize: '0.95rem',
|
||||
color: active ? '#2563eb' : '#555',
|
||||
borderBottom: active ? '2px solid #2563eb' : '2px solid transparent',
|
||||
marginBottom: '-2px',
|
||||
borderRadius: '4px 4px 0 0',
|
||||
background: active ? '#eff6ff' : 'transparent',
|
||||
transition: 'background 0.15s',
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { auth } from '../../auth';
|
||||
import Navigation from '../Navigation';
|
||||
import ProfileTabs from './ProfileTabs';
|
||||
import MinProfilTab from './tabs/MinProfilTab';
|
||||
|
||||
export const metadata = { title: 'Min profil' };
|
||||
|
||||
type Props = {
|
||||
searchParams: Promise<{ tab?: string }>;
|
||||
};
|
||||
|
||||
export default async function ProfilPage({ searchParams }: Props) {
|
||||
const { tab = 'profil' } = await searchParams;
|
||||
const session = await auth();
|
||||
const isAdmin = (session?.user as any)?.role === 'admin';
|
||||
|
||||
// DatabsTab och AnvandareTab laddas dynamiskt för att hålla page.tsx tunn
|
||||
let TabContent: React.ComponentType;
|
||||
if (tab === 'databas') {
|
||||
const { default: DatabsTab } = await import('./tabs/DatabsTab');
|
||||
TabContent = DatabsTab;
|
||||
} else if (tab === 'anvandare' && isAdmin) {
|
||||
const { default: AnvandareTab } = await import('./tabs/AnvandareTab');
|
||||
TabContent = AnvandareTab;
|
||||
} else if (tab === 'forslag' && isAdmin) {
|
||||
const { default: ForslagTab } = await import('./tabs/ForslagTab');
|
||||
TabContent = ForslagTab;
|
||||
} else if (tab === 'ai' && isAdmin) {
|
||||
const { default: AiTab } = await import('./tabs/AiTab');
|
||||
TabContent = AiTab;
|
||||
} else {
|
||||
TabContent = MinProfilTab;
|
||||
}
|
||||
|
||||
const adminTabs = ['anvandare', 'forslag', 'ai'];
|
||||
const userTabs = ['databas'];
|
||||
const activeTab =
|
||||
(isAdmin && (adminTabs.includes(tab) || userTabs.includes(tab))) ||
|
||||
userTabs.includes(tab)
|
||||
? tab
|
||||
: 'profil';
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navigation />
|
||||
<main style={{ padding: '1rem', maxWidth: '1200px', margin: '0 auto' }}>
|
||||
<h1 style={{ marginBottom: '1.5rem' }}>Min profil</h1>
|
||||
<ProfileTabs activeTab={activeTab} isAdmin={isAdmin} />
|
||||
<TabContent />
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import AiAdminClient from '../../admin/ai/AiAdminClient';
|
||||
import type { AiModelInfo } from '../../admin/ai/AiAdminClient';
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080';
|
||||
|
||||
export default async function AiTab() {
|
||||
const key = process.env.MISTRAL_API_KEY ?? '';
|
||||
const hasKey = key.length > 0;
|
||||
const keyHint = key.length >= 4 ? key.slice(-4) : '????';
|
||||
|
||||
let aiFunctions: AiModelInfo[] = [];
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/ai/models`, { cache: 'no-store' });
|
||||
if (res.ok) aiFunctions = await res.json();
|
||||
} catch {
|
||||
// backend ej nåbart
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 style={{ fontSize: '1.1rem', marginBottom: '0.25rem' }}>🤖 AI-konfiguration</h2>
|
||||
<p style={{ color: '#666', marginBottom: '2rem', fontSize: '0.9rem' }}>
|
||||
Översikt över implementerade AI-funktioner och API-nyckelstatus.
|
||||
</p>
|
||||
<AiAdminClient keyHint={keyHint} hasKey={hasKey} aiFunctions={aiFunctions} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,611 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
role: string;
|
||||
isPremium: boolean;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface ResetResult {
|
||||
to: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
temporaryPassword: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
users: User[];
|
||||
currentUserId: number;
|
||||
}
|
||||
|
||||
export default function AnvandareClient({ users: initial, currentUserId }: Props) {
|
||||
const [users, setUsers] = useState<User[]>(initial);
|
||||
const [error, setError] = useState('');
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createForm, setCreateForm] = useState({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
role: 'user',
|
||||
});
|
||||
const [createError, setCreateError] = useState('');
|
||||
|
||||
// Lösenordsåterställning modal
|
||||
const [resetResult, setResetResult] = useState<ResetResult | null>(null);
|
||||
const [copiedBody, setCopiedBody] = useState(false);
|
||||
const [copiedPw, setCopiedPw] = useState(false);
|
||||
|
||||
// Inline e-postbyte
|
||||
const [editingEmailId, setEditingEmailId] = useState<number | null>(null);
|
||||
const [editingEmail, setEditingEmail] = useState('');
|
||||
|
||||
async function handleCreate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setCreating(true);
|
||||
setCreateError('');
|
||||
try {
|
||||
const res = await fetch('/api/admin-users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(createForm),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.message ?? 'Kunde inte skapa användare');
|
||||
setUsers((prev) => [...prev, data]);
|
||||
setShowCreate(false);
|
||||
setCreateForm({ username: '', email: '', password: '', role: 'user' });
|
||||
} catch (err: any) {
|
||||
setCreateError(err.message);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
if (!confirm('Är du säker på att du vill ta bort användaren?')) return;
|
||||
setError('');
|
||||
const res = await fetch(`/api/admin-users/${id}`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
setUsers((prev) => prev.filter((u) => u.id !== id));
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setError(data.message ?? 'Kunde inte ta bort användaren');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRoleChange(id: number, role: string) {
|
||||
setError('');
|
||||
const res = await fetch(`/api/admin-users/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ role }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const updated = await res.json();
|
||||
setUsers((prev) => prev.map((u) => (u.id === id ? { ...u, role: updated.role } : u)));
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setError(data.message ?? 'Kunde inte ändra roll');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResetPassword(id: number) {
|
||||
setError('');
|
||||
const res = await fetch(`/api/admin-users/${id}/reset-password`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(data.message ?? 'Kunde inte återställa lösenord');
|
||||
return;
|
||||
}
|
||||
setResetResult(data);
|
||||
setCopiedBody(false);
|
||||
setCopiedPw(false);
|
||||
}
|
||||
|
||||
async function handlePremiumChange(id: number, isPremium: boolean) {
|
||||
setError('');
|
||||
const res = await fetch(`/api/admin-users/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ isPremium }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setUsers((prev) => prev.map((u) => (u.id === id ? { ...u, isPremium } : u)));
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setError(data.message ?? 'Kunde inte ändra plan');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEmailSave(id: number) {
|
||||
setError('');
|
||||
const res = await fetch(`/api/admin-users/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: editingEmail }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const updated = await res.json();
|
||||
setUsers((prev) => prev.map((u) => (u.id === id ? { ...u, email: updated.email } : u)));
|
||||
setEditingEmailId(null);
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setError(data.message ?? 'Kunde inte uppdatera e-post');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Lägg till användare */}
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<button
|
||||
onClick={() => setShowCreate((v) => !v)}
|
||||
style={{
|
||||
background: '#2563eb',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 6,
|
||||
padding: '0.5rem 1.2rem',
|
||||
cursor: 'pointer',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{showCreate ? '− Stäng' : '+ Lägg till användare'}
|
||||
</button>
|
||||
|
||||
{showCreate && (
|
||||
<form
|
||||
onSubmit={handleCreate}
|
||||
style={{
|
||||
marginTop: '1rem',
|
||||
background: '#f8fafc',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 8,
|
||||
padding: '1rem 1.5rem',
|
||||
maxWidth: 420,
|
||||
}}
|
||||
>
|
||||
<h3 style={{ marginTop: 0, marginBottom: '1rem' }}>Ny användare</h3>
|
||||
{createError && (
|
||||
<div style={{ color: '#dc2626', marginBottom: '0.75rem', fontSize: 14 }}>
|
||||
{createError}
|
||||
</div>
|
||||
)}
|
||||
{[
|
||||
{ key: 'username', label: 'Användarnamn', type: 'text' },
|
||||
{ key: 'email', label: 'E-post', type: 'email' },
|
||||
{ key: 'password', label: 'Lösenord', type: 'password' },
|
||||
].map(({ key, label, type }) => (
|
||||
<div key={key} style={{ marginBottom: '0.75rem' }}>
|
||||
<label style={{ display: 'block', fontSize: 13, marginBottom: 4 }}>{label}</label>
|
||||
<input
|
||||
type={type}
|
||||
value={(createForm as any)[key]}
|
||||
onChange={(e) => setCreateForm((f) => ({ ...f, [key]: e.target.value }))}
|
||||
required
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '0.4rem 0.6rem',
|
||||
border: '1px solid #cbd5e1',
|
||||
borderRadius: 4,
|
||||
fontSize: 14,
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<label style={{ display: 'block', fontSize: 13, marginBottom: 4 }}>Roll</label>
|
||||
<select
|
||||
value={createForm.role}
|
||||
onChange={(e) => setCreateForm((f) => ({ ...f, role: e.target.value }))}
|
||||
style={{
|
||||
padding: '0.4rem 0.6rem',
|
||||
border: '1px solid #cbd5e1',
|
||||
borderRadius: 4,
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
<option value="user">Användare</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={creating}
|
||||
style={{
|
||||
background: '#16a34a',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 6,
|
||||
padding: '0.5rem 1.2rem',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{creating ? 'Skapar...' : 'Skapa'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
background: '#fef2f2',
|
||||
border: '1px solid #fecaca',
|
||||
borderRadius: 6,
|
||||
padding: '0.6rem 1rem',
|
||||
color: '#dc2626',
|
||||
marginBottom: '1rem',
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Användartabell */}
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#f1f5f9', textAlign: 'left' }}>
|
||||
{['Användare', 'E-post', 'Roll', 'Plan', 'Åtgärder'].map((h) => (
|
||||
<th
|
||||
key={h}
|
||||
style={{ padding: '0.6rem 0.8rem', borderBottom: '2px solid #e2e8f0' }}
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((user) => {
|
||||
const isSelf = user.id === currentUserId;
|
||||
const isEditingEmail = editingEmailId === user.id;
|
||||
return (
|
||||
<tr
|
||||
key={user.id}
|
||||
style={{ borderBottom: '1px solid #e2e8f0', background: isSelf ? '#f0f9ff' : 'transparent' }}
|
||||
>
|
||||
{/* Namn */}
|
||||
<td style={{ padding: '0.6rem 0.8rem' }}>
|
||||
<div style={{ fontWeight: 500 }}>{user.username}</div>
|
||||
{(user.firstName || user.lastName) && (
|
||||
<div style={{ color: '#64748b', fontSize: 12 }}>
|
||||
{[user.firstName, user.lastName].filter(Boolean).join(' ')}
|
||||
</div>
|
||||
)}
|
||||
{isSelf && (
|
||||
<span style={{ fontSize: 11, color: '#2563eb', fontStyle: 'italic' }}>
|
||||
Du själv
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* E-post */}
|
||||
<td style={{ padding: '0.6rem 0.8rem' }}>
|
||||
{isEditingEmail ? (
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<input
|
||||
type="email"
|
||||
value={editingEmail}
|
||||
onChange={(e) => setEditingEmail(e.target.value)}
|
||||
style={{
|
||||
padding: '0.3rem 0.5rem',
|
||||
border: '1px solid #93c5fd',
|
||||
borderRadius: 4,
|
||||
fontSize: 13,
|
||||
width: 180,
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleEmailSave(user.id)}
|
||||
style={{
|
||||
background: '#16a34a',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 4,
|
||||
padding: '0.2rem 0.5rem',
|
||||
cursor: 'pointer',
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
Spara
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingEmailId(null)}
|
||||
style={{
|
||||
background: '#e2e8f0',
|
||||
border: 'none',
|
||||
borderRadius: 4,
|
||||
padding: '0.2rem 0.5rem',
|
||||
cursor: 'pointer',
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
Avbryt
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{ color: '#334155' }}>{user.email}</span>
|
||||
{!isSelf && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingEmailId(user.id);
|
||||
setEditingEmail(user.email);
|
||||
}}
|
||||
title="Ändra e-post"
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
color: '#64748b',
|
||||
fontSize: 13,
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Roll */}
|
||||
<td style={{ padding: '0.6rem 0.8rem' }}>
|
||||
{isSelf ? (
|
||||
<RoleBadge role={user.role} />
|
||||
) : (
|
||||
<select
|
||||
value={user.role}
|
||||
onChange={(e) => handleRoleChange(user.id, e.target.value)}
|
||||
style={{
|
||||
padding: '0.25rem 0.4rem',
|
||||
border: '1px solid #cbd5e1',
|
||||
borderRadius: 4,
|
||||
fontSize: 13,
|
||||
background: user.role === 'admin' ? '#eff6ff' : '#f8fafc',
|
||||
}}
|
||||
>
|
||||
<option value="user">Användare</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Plan */}
|
||||
<td style={{ padding: '0.6rem 0.8rem' }}>
|
||||
<select
|
||||
value={user.isPremium ? 'paid' : 'free'}
|
||||
onChange={(e) => handlePremiumChange(user.id, e.target.value === 'paid')}
|
||||
style={{
|
||||
padding: '0.25rem 0.4rem',
|
||||
border: '1px solid #cbd5e1',
|
||||
borderRadius: 4,
|
||||
fontSize: 13,
|
||||
background: user.isPremium ? '#fef9c3' : '#f8fafc',
|
||||
color: user.isPremium ? '#854d0e' : '#334155',
|
||||
fontWeight: user.isPremium ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
<option value="free">Free</option>
|
||||
<option value="paid">Paid ✨</option>
|
||||
</select>
|
||||
</td>
|
||||
|
||||
{/* Åtgärder */}
|
||||
<td style={{ padding: '0.6rem 0.8rem' }}>
|
||||
{isSelf ? (
|
||||
<span style={{ color: '#94a3b8', fontSize: 12 }}>—</span>
|
||||
) : (
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button
|
||||
onClick={() => handleResetPassword(user.id)}
|
||||
title="Återställ lösenord"
|
||||
style={{
|
||||
background: '#fef3c7',
|
||||
border: '1px solid #fcd34d',
|
||||
borderRadius: 4,
|
||||
padding: '0.3rem 0.6rem',
|
||||
cursor: 'pointer',
|
||||
fontSize: 12,
|
||||
color: '#92400e',
|
||||
}}
|
||||
>
|
||||
Återställ lösenord
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(user.id)}
|
||||
title="Ta bort"
|
||||
style={{
|
||||
background: '#fef2f2',
|
||||
border: '1px solid #fecaca',
|
||||
borderRadius: 4,
|
||||
padding: '0.3rem 0.6rem',
|
||||
cursor: 'pointer',
|
||||
fontSize: 12,
|
||||
color: '#dc2626',
|
||||
}}
|
||||
>
|
||||
Ta bort
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Lösenordsåterställning modal */}
|
||||
{resetResult && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.45)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: '#fff',
|
||||
borderRadius: 10,
|
||||
padding: '1.5rem 2rem',
|
||||
maxWidth: 520,
|
||||
width: '90%',
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.18)',
|
||||
}}
|
||||
>
|
||||
<h3 style={{ marginTop: 0 }}>Lösenordet har återställts</h3>
|
||||
<p style={{ fontSize: 13, color: '#475569' }}>
|
||||
Skicka nedanstående meddelande till användaren och/eller ge dem det tillfälliga lösenordet.
|
||||
</p>
|
||||
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
|
||||
<label style={{ fontSize: 13, fontWeight: 500 }}>Meddelande att skicka till användaren</label>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(resetResult.body);
|
||||
setCopiedBody(true);
|
||||
}}
|
||||
style={{
|
||||
background: copiedBody ? '#dcfce7' : '#f1f5f9',
|
||||
border: '1px solid #cbd5e1',
|
||||
borderRadius: 4,
|
||||
padding: '0.2rem 0.6rem',
|
||||
cursor: 'pointer',
|
||||
fontSize: 12,
|
||||
color: copiedBody ? '#16a34a' : '#334155',
|
||||
}}
|
||||
>
|
||||
{copiedBody ? '✓ Kopierat' : 'Kopiera meddelande'}
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
readOnly
|
||||
value={resetResult.body}
|
||||
rows={6}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '0.5rem',
|
||||
fontSize: 13,
|
||||
fontFamily: 'monospace',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 6,
|
||||
background: '#f8fafc',
|
||||
resize: 'vertical',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
|
||||
<label style={{ fontSize: 13, fontWeight: 500 }}>Tillfälligt lösenord</label>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(resetResult.temporaryPassword);
|
||||
setCopiedPw(true);
|
||||
}}
|
||||
style={{
|
||||
background: copiedPw ? '#dcfce7' : '#f1f5f9',
|
||||
border: '1px solid #cbd5e1',
|
||||
borderRadius: 4,
|
||||
padding: '0.2rem 0.6rem',
|
||||
cursor: 'pointer',
|
||||
fontSize: 12,
|
||||
color: copiedPw ? '#16a34a' : '#334155',
|
||||
}}
|
||||
>
|
||||
{copiedPw ? '✓ Kopierat' : 'Kopiera lösenord'}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 18,
|
||||
fontWeight: 700,
|
||||
letterSpacing: 2,
|
||||
padding: '0.5rem 1rem',
|
||||
background: '#fff7ed',
|
||||
border: '1px solid #fed7aa',
|
||||
borderRadius: 6,
|
||||
color: '#9a3412',
|
||||
userSelect: 'all',
|
||||
}}
|
||||
>
|
||||
{resetResult.temporaryPassword}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!copiedBody && !copiedPw && (
|
||||
<div
|
||||
style={{
|
||||
background: '#fefce8',
|
||||
border: '1px solid #fde68a',
|
||||
borderRadius: 6,
|
||||
padding: '0.5rem 0.75rem',
|
||||
fontSize: 12,
|
||||
color: '#92400e',
|
||||
marginBottom: '1rem',
|
||||
}}
|
||||
>
|
||||
⚠ Kopiera lösenordet eller meddelandet innan du stänger — det visas inte igen.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setResetResult(null)}
|
||||
style={{
|
||||
background: '#e2e8f0',
|
||||
border: 'none',
|
||||
borderRadius: 6,
|
||||
padding: '0.5rem 1.2rem',
|
||||
cursor: 'pointer',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Stäng
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RoleBadge({ role }: { role: string }) {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: '0.2rem 0.6rem',
|
||||
borderRadius: 12,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
background: role === 'admin' ? '#eff6ff' : '#f0fdf4',
|
||||
color: role === 'admin' ? '#1d4ed8' : '#15803d',
|
||||
border: `1px solid ${role === 'admin' ? '#bfdbfe' : '#bbf7d0'}`,
|
||||
}}
|
||||
>
|
||||
{role === 'admin' ? 'Admin' : 'Användare'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { auth } from '../../../auth';
|
||||
import { getAuthHeaders } from '../../../lib/auth-headers';
|
||||
import AnvandareClient from './AnvandareClient';
|
||||
|
||||
export default async function AnvandareTab() {
|
||||
const session = await auth();
|
||||
const userId = Number(session?.user?.id ?? 0);
|
||||
|
||||
const headers = await getAuthHeaders();
|
||||
const res = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL_INTERNAL ?? 'http://recipe-api:8080'}/api/users`,
|
||||
{ headers, cache: 'no-store' },
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
console.error(`[AnvandareTab] GET /api/users misslyckades: ${res.status}`, text);
|
||||
}
|
||||
|
||||
const users = res.ok ? await res.json() : [];
|
||||
|
||||
return <AnvandareClient users={users} currentUserId={userId} />;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import ProductsView from './views/ProductsView';
|
||||
import InventoryView from './views/InventoryView';
|
||||
import PantryView from './views/PantryView';
|
||||
|
||||
type TableId = 'inventory' | 'pantry' | 'products';
|
||||
|
||||
type TableDef = {
|
||||
id: TableId;
|
||||
label: string;
|
||||
adminOnly: boolean;
|
||||
};
|
||||
|
||||
const TABLES: TableDef[] = [
|
||||
{ id: 'inventory', label: '🏠 Inventarie', adminOnly: false },
|
||||
{ id: 'pantry', label: '📦 Baslager', adminOnly: false },
|
||||
{ id: 'products', label: '🗄️ Produkter', adminOnly: true },
|
||||
];
|
||||
|
||||
const tabStyle = (active: boolean): React.CSSProperties => ({
|
||||
padding: '0.5rem 1.1rem',
|
||||
border: 'none',
|
||||
borderBottom: active ? '2.5px solid #2563eb' : '2.5px solid transparent',
|
||||
background: active ? '#eff6ff' : 'none',
|
||||
cursor: 'pointer',
|
||||
fontWeight: active ? 600 : 400,
|
||||
color: active ? '#2563eb' : '#555',
|
||||
fontSize: '0.95rem',
|
||||
borderRadius: '4px 4px 0 0',
|
||||
transition: 'color 0.15s, border-color 0.15s, background 0.15s',
|
||||
});
|
||||
|
||||
export default function DatabsTab() {
|
||||
const { data: session } = useSession();
|
||||
const isAdmin = (session?.user as any)?.role === 'admin';
|
||||
|
||||
const visibleTables = TABLES.filter((t) => !t.adminOnly || isAdmin);
|
||||
|
||||
const [activeTable, setActiveTable] = useState<TableId>('inventory');
|
||||
|
||||
// Om vald tabell inte är tillgänglig för rollen, fall tillbaka på första
|
||||
const safeActive = visibleTables.find((t) => t.id === activeTable)
|
||||
? activeTable
|
||||
: visibleTables[0]?.id ?? 'inventory';
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Tabellväljare */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '0.25rem',
|
||||
borderBottom: '1px solid #ddd',
|
||||
marginBottom: '1.75rem',
|
||||
}}
|
||||
>
|
||||
{visibleTables.map((table) => (
|
||||
<button
|
||||
key={table.id}
|
||||
style={tabStyle(safeActive === table.id)}
|
||||
onClick={() => setActiveTable(table.id)}
|
||||
>
|
||||
{table.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{safeActive === 'inventory' && <InventoryView />}
|
||||
{safeActive === 'pantry' && <PantryView />}
|
||||
{safeActive === 'products' && isAdmin && <ProductsView />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { getAuthHeaders } from '../../../lib/auth-headers';
|
||||
import PendingProductsClient from '../../admin/products/pending/PendingProductsClient';
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL ?? 'http://recipe-api:8080';
|
||||
|
||||
export default async function ForslagTab() {
|
||||
const headers = await getAuthHeaders();
|
||||
let products: any[] = [];
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/products/pending`, { headers, cache: 'no-store' });
|
||||
if (res.ok) products = await res.json();
|
||||
} catch {
|
||||
// backend ej nåbart
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 style={{ fontSize: '1.1rem', marginBottom: '0.5rem' }}>Väntande produktförslag</h2>
|
||||
<p style={{ color: '#64748b', marginBottom: '1.5rem', fontSize: '0.9rem' }}>
|
||||
Produkter som användare har föreslagit. Godkänn för att göra dem tillgängliga i katalogen, eller avvisa för att ta bort dem.
|
||||
</p>
|
||||
<PendingProductsClient products={products} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, FormEvent } from 'react';
|
||||
|
||||
type Profile = {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
};
|
||||
|
||||
export default function ProfileClient() {
|
||||
const [profile, setProfile] = useState<Profile | null>(null);
|
||||
const [form, setForm] = useState({ firstName: '', lastName: '', email: '' });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/profile')
|
||||
.then((r) => r.json())
|
||||
.then((data: Profile) => {
|
||||
setProfile(data);
|
||||
setForm({
|
||||
firstName: data.firstName ?? '',
|
||||
lastName: data.lastName ?? '',
|
||||
email: data.email,
|
||||
});
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setSuccess(false);
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch('/api/profile', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
firstName: form.firstName || null,
|
||||
lastName: form.lastName || null,
|
||||
email: form.email,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setError(data.message ?? 'Kunde inte spara ändringar');
|
||||
} else {
|
||||
setSuccess(true);
|
||||
}
|
||||
} catch {
|
||||
setError('Något gick fel');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid #ddd',
|
||||
fontSize: '1rem',
|
||||
boxSizing: 'border-box',
|
||||
};
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
display: 'block',
|
||||
marginBottom: 6,
|
||||
fontWeight: 500,
|
||||
fontSize: '0.9rem',
|
||||
color: '#444',
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <p style={{ color: '#666' }}>Laddar profil...</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 480 }}>
|
||||
{/* Initialer/avatar */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '2rem' }}>
|
||||
<div
|
||||
style={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: '50%',
|
||||
background: '#2563eb',
|
||||
color: 'white',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '1.5rem',
|
||||
fontWeight: 700,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{profile?.username?.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: '1.1rem' }}>{profile?.username}</div>
|
||||
<div style={{ color: '#666', fontSize: '0.9rem' }}>{profile?.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
||||
<div>
|
||||
<label htmlFor="firstName" style={labelStyle}>Förnamn</label>
|
||||
<input
|
||||
id="firstName"
|
||||
type="text"
|
||||
value={form.firstName}
|
||||
onChange={(e) => setForm((f) => ({ ...f, firstName: e.target.value }))}
|
||||
style={inputStyle}
|
||||
maxLength={100}
|
||||
autoComplete="given-name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="lastName" style={labelStyle}>Efternamn</label>
|
||||
<input
|
||||
id="lastName"
|
||||
type="text"
|
||||
value={form.lastName}
|
||||
onChange={(e) => setForm((f) => ({ ...f, lastName: e.target.value }))}
|
||||
style={inputStyle}
|
||||
maxLength={100}
|
||||
autoComplete="family-name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" style={labelStyle}>E-post</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))}
|
||||
required
|
||||
style={inputStyle}
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>Användarnamn</label>
|
||||
<input
|
||||
type="text"
|
||||
value={profile?.username ?? ''}
|
||||
disabled
|
||||
style={{ ...inputStyle, background: '#f5f5f5', color: '#888', cursor: 'not-allowed' }}
|
||||
/>
|
||||
<p style={{ fontSize: '0.8rem', color: '#999', margin: '4px 0 0' }}>
|
||||
Användarnamn kan inte ändras
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && <p style={{ color: '#dc2626', margin: 0 }}>{error}</p>}
|
||||
{success && <p style={{ color: '#16a34a', margin: 0 }}>Ändringarna sparades!</p>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
style={{
|
||||
padding: '10px',
|
||||
background: '#2563eb',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: 6,
|
||||
fontSize: '1rem',
|
||||
cursor: saving ? 'not-allowed' : 'pointer',
|
||||
opacity: saving ? 0.7 : 1,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{saving ? 'Sparar...' : 'Spara ändringar'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import type { InventoryItem, Product } from '../../../../features/inventory/types';
|
||||
import InventoryList from '../../../inventory/InventoryList';
|
||||
import InventoryForm from '../../../inventory/InventoryForm';
|
||||
import { useAuthFetch } from '../../../../lib/use-auth-fetch';
|
||||
|
||||
export default function InventoryView() {
|
||||
const [inventory, setInventory] = useState<InventoryItem[]>([]);
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const authFetch = useAuthFetch();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [invRes, prodRes] = await Promise.all([
|
||||
authFetch('/api/inventory'),
|
||||
fetch('/api/products'),
|
||||
]);
|
||||
if (!invRes.ok) throw new Error('Kunde inte hämta inventarie');
|
||||
if (!prodRes.ok) throw new Error('Kunde inte hämta produkter');
|
||||
const [inv, prods] = await Promise.all([invRes.json(), prodRes.json()]);
|
||||
setInventory(inv);
|
||||
setProducts(prods);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Okänt fel');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [authFetch]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
if (loading) return <p style={{ color: '#888' }}>Laddar inventarie…</p>;
|
||||
if (error) return <p style={{ color: '#c00' }}>{error}</p>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p style={{ color: '#555', marginBottom: '1rem' }}>
|
||||
Lägg till, redigera och ta bort varor i ditt inventarie.
|
||||
</p>
|
||||
|
||||
{/* Formulär för att lägga till vara — viker ut sig vid klick */}
|
||||
<InventoryForm products={products} onCreated={load} />
|
||||
|
||||
{/* Lista med redigera/ta bort per rad */}
|
||||
<InventoryList inventory={inventory} onDeleted={load} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import type { Product } from '../../../../features/inventory/types';
|
||||
import AddToPantryForm from '../../../baslager/AddToPantryForm';
|
||||
import PantryList from '../../../baslager/PantryList';
|
||||
import { useAuthFetch } from '../../../../lib/use-auth-fetch';
|
||||
|
||||
type PantryItem = {
|
||||
id: number;
|
||||
productId: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
product: Product;
|
||||
};
|
||||
|
||||
export default function PantryView() {
|
||||
const [pantryItems, setPantryItems] = useState<PantryItem[]>([]);
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const authFetch = useAuthFetch();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [pantryRes, prodRes] = await Promise.all([
|
||||
authFetch('/api/pantry'),
|
||||
fetch('/api/products'),
|
||||
]);
|
||||
if (!pantryRes.ok) throw new Error('Kunde inte hämta baslager');
|
||||
if (!prodRes.ok) throw new Error('Kunde inte hämta produkter');
|
||||
const [pantry, prods] = await Promise.all([pantryRes.json(), prodRes.json()]);
|
||||
setPantryItems(pantry);
|
||||
setProducts(prods);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Okänt fel');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [authFetch]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
if (loading) return <p style={{ color: '#888' }}>Laddar baslager…</p>;
|
||||
if (error) return <p style={{ color: '#c00' }}>{error}</p>;
|
||||
|
||||
const pantryProductIds = new Set(pantryItems.map((i) => i.productId));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p style={{ color: '#555', marginBottom: '1rem' }}>
|
||||
Produkter du alltid räknar med att ha hemma. Lägg till och ta bort varor i ditt baslager.
|
||||
</p>
|
||||
|
||||
<section style={{ marginBottom: '2rem' }}>
|
||||
<h2 style={{ fontSize: '1.1rem', marginBottom: '0.75rem' }}>Lägg till produkt</h2>
|
||||
<AddToPantryForm products={products} pantryProductIds={pantryProductIds} onCreated={load} />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 style={{ fontSize: '1.1rem', marginBottom: '0.75rem' }}>
|
||||
{pantryItems.length} {pantryItems.length === 1 ? 'produkt' : 'produkter'} i baslagret
|
||||
</h2>
|
||||
<PantryList items={pantryItems} onDeleted={load} />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import MergePreviewForm from '../../../admin/products/MergePreviewForm';
|
||||
import AdminProductList from '../../../admin/products/AdminProductList';
|
||||
import ExpandableCreateProductSection from '../../../admin/products/ExpandableCreateProductSection';
|
||||
import ResetProductsButton from '../../../admin/products/ResetProductsButton';
|
||||
import DeletedProductsView from '../../../admin/products/DeletedProductsView';
|
||||
|
||||
const subTabs = [
|
||||
{ id: 'varor', label: '📦 Varor' },
|
||||
{ id: 'skapa-merge', label: '➕ Skapa / Slå ihop' },
|
||||
{ id: 'papperskorg', label: '🗑️ Papperskorg' },
|
||||
] as const;
|
||||
|
||||
type SubTabId = typeof subTabs[number]['id'];
|
||||
|
||||
const tabStyle = (active: boolean): React.CSSProperties => ({
|
||||
padding: '0.4rem 1rem',
|
||||
border: 'none',
|
||||
borderBottom: active ? '2.5px solid #333' : '2.5px solid transparent',
|
||||
background: 'none',
|
||||
cursor: 'pointer',
|
||||
fontWeight: active ? 600 : 400,
|
||||
color: active ? '#111' : '#666',
|
||||
fontSize: '0.95rem',
|
||||
transition: 'color 0.15s, border-color 0.15s',
|
||||
});
|
||||
|
||||
export default function ProductsView() {
|
||||
const [activeSubTab, setActiveSubTab] = useState<SubTabId>('varor');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', gap: '0.25rem', borderBottom: '1px solid #ddd', marginBottom: '1.5rem' }}>
|
||||
{subTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
style={tabStyle(activeSubTab === tab.id)}
|
||||
onClick={() => setActiveSubTab(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeSubTab === 'varor' && (
|
||||
<div>
|
||||
<p style={{ color: '#555', marginBottom: '1rem' }}>
|
||||
Granska och standardisera produktnamn samt hantera kategorier.
|
||||
</p>
|
||||
<AdminProductList />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSubTab === 'skapa-merge' && (
|
||||
<div>
|
||||
<p style={{ color: '#555', marginBottom: '1rem' }}>
|
||||
Skapa ny produkt, återställ produktdatabas eller slå ihop dubbletter.
|
||||
</p>
|
||||
<ExpandableCreateProductSection />
|
||||
<ResetProductsButton />
|
||||
<MergePreviewForm />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSubTab === 'papperskorg' && <DeletedProductsView />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user