feat: new users role
This commit is contained in:
parent
486d94335b
commit
e345aeee97
1
.vscode/settings.json
vendored
Normal file
1
.vscode/settings.json
vendored
Normal file
@ -0,0 +1 @@
|
||||
{}
|
||||
@ -3,11 +3,12 @@ package constants
|
||||
type UserRole string
|
||||
|
||||
const (
|
||||
RoleAdmin UserRole = "admin"
|
||||
RoleManager UserRole = "manager"
|
||||
RoleCashier UserRole = "cashier"
|
||||
RoleWaiter UserRole = "waiter"
|
||||
RoleOwner UserRole = "owner"
|
||||
RoleAdmin UserRole = "admin"
|
||||
RoleManager UserRole = "manager"
|
||||
RoleCashier UserRole = "cashier"
|
||||
RoleWaiter UserRole = "waiter"
|
||||
RoleOwner UserRole = "owner"
|
||||
RolePurchasing UserRole = "purchasing"
|
||||
)
|
||||
|
||||
func GetAllUserRoles() []UserRole {
|
||||
@ -17,6 +18,7 @@ func GetAllUserRoles() []UserRole {
|
||||
RoleCashier,
|
||||
RoleWaiter,
|
||||
RoleOwner,
|
||||
RolePurchasing,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -12,14 +12,14 @@ type CreateUserRequest struct {
|
||||
Name string `json:"name" validate:"required,min=1,max=255"`
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
Password string `json:"password" validate:"required,min=6"`
|
||||
Role string `json:"role" validate:"required,oneof=admin manager cashier waiter"`
|
||||
Role string `json:"role" validate:"required,oneof=admin manager cashier waiter owner purchasing"`
|
||||
Permissions map[string]interface{} `json:"permissions,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateUserRequest struct {
|
||||
Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"`
|
||||
Email *string `json:"email,omitempty" validate:"omitempty,email"`
|
||||
Role *string `json:"role,omitempty" validate:"omitempty,oneof=admin manager cashier waiter"`
|
||||
Role *string `json:"role,omitempty" validate:"omitempty,oneof=admin manager cashier waiter owner purchasing"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
IsActive *bool `json:"is_active,omitempty"`
|
||||
Permissions *map[string]interface{} `json:"permissions,omitempty"`
|
||||
|
||||
@ -13,10 +13,12 @@ import (
|
||||
type UserRole string
|
||||
|
||||
const (
|
||||
RoleAdmin UserRole = "admin"
|
||||
RoleManager UserRole = "manager"
|
||||
RoleCashier UserRole = "cashier"
|
||||
RoleWaiter UserRole = "waiter"
|
||||
RoleAdmin UserRole = "admin"
|
||||
RoleManager UserRole = "manager"
|
||||
RoleCashier UserRole = "cashier"
|
||||
RoleWaiter UserRole = "waiter"
|
||||
RoleOwner UserRole = "owner"
|
||||
RolePurchasing UserRole = "purchasing"
|
||||
)
|
||||
|
||||
type Permissions map[string]interface{}
|
||||
@ -46,7 +48,7 @@ type User struct {
|
||||
Name string `gorm:"not null;size:255" json:"name" validate:"required,min=1,max=255"`
|
||||
Email string `gorm:"uniqueIndex;not null;size:255" json:"email" validate:"required,email"`
|
||||
PasswordHash string `gorm:"not null;size:255" json:"-"`
|
||||
Role UserRole `gorm:"not null;size:50" json:"role" validate:"required,oneof=admin manager cashier waiter"`
|
||||
Role UserRole `gorm:"not null;size:50" json:"role" validate:"required,oneof=admin manager cashier waiter owner purchasing"`
|
||||
Permissions Permissions `gorm:"type:jsonb;default:'{}'" json:"permissions"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
|
||||
@ -66,3 +66,35 @@ func (h *ReportHandler) GetDailyTransactionReportPDF(c *gin.Context) {
|
||||
"file_name": fileName,
|
||||
}), "ReportHandler::GetDailyTransactionReportPDF")
|
||||
}
|
||||
|
||||
func (h *ReportHandler) GetProfitLossReportPDF(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
ci := appcontext.FromGinContext(ctx)
|
||||
|
||||
outletID := h.resolveOutletID(c, ci.OutletID)
|
||||
var dayPtr *time.Time
|
||||
if d := c.Query("date"); d != "" {
|
||||
if t, err := time.Parse("2006-01-02", d); err == nil {
|
||||
dayPtr = &t
|
||||
}
|
||||
}
|
||||
|
||||
user, err := h.userService.GetUserByID(ctx, ci.UserID)
|
||||
var genBy string
|
||||
if err != nil {
|
||||
genBy = ci.UserID.String()
|
||||
} else {
|
||||
genBy = user.Name
|
||||
}
|
||||
|
||||
publicURL, fileName, err := h.reportService.GenerateProfitLossPDF(ctx, ci.OrganizationID.String(), outletID, dayPtr, genBy)
|
||||
if err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "ReportHandler::GetProfitLossReportPDF", err.Error())}), "ReportHandler::GetProfitLossReportPDF")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(map[string]string{
|
||||
"url": publicURL,
|
||||
"file_name": fileName,
|
||||
}), "ReportHandler::GetProfitLossReportPDF")
|
||||
}
|
||||
|
||||
@ -63,10 +63,12 @@ type UserResponse struct {
|
||||
|
||||
func (u *User) HasPermission(requiredRole constants.UserRole) bool {
|
||||
roleHierarchy := map[constants.UserRole]int{
|
||||
constants.RoleWaiter: 1,
|
||||
constants.RoleCashier: 2,
|
||||
constants.RoleManager: 3,
|
||||
constants.RoleAdmin: 4,
|
||||
constants.RoleWaiter: 1,
|
||||
constants.RoleCashier: 2,
|
||||
constants.RolePurchasing: 3,
|
||||
constants.RoleManager: 4,
|
||||
constants.RoleAdmin: 5,
|
||||
constants.RoleOwner: 6,
|
||||
}
|
||||
|
||||
userLevel := roleHierarchy[u.Role]
|
||||
|
||||
@ -620,6 +620,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
||||
outlets.GET("/:outlet_id/tables/occupied", r.tableHandler.GetOccupiedTables)
|
||||
// Reports
|
||||
outlets.GET("/:outlet_id/reports/daily-transaction.pdf", r.reportHandler.GetDailyTransactionReportPDF)
|
||||
outlets.GET("/:outlet_id/reports/profit-loss.pdf", r.reportHandler.GetProfitLossReportPDF)
|
||||
}
|
||||
|
||||
// User device routes - accessible by authenticated users for their own devices
|
||||
|
||||
@ -17,6 +17,7 @@ import (
|
||||
type ReportService interface {
|
||||
// Returns (publicURL, fileName, error)
|
||||
GenerateDailyTransactionPDF(ctx context.Context, organizationID string, outletID string, reportDate *time.Time, generatedBy string) (string, string, error)
|
||||
GenerateProfitLossPDF(ctx context.Context, organizationID string, outletID string, reportDate *time.Time, generatedBy string) (string, string, error)
|
||||
}
|
||||
|
||||
type ReportServiceImpl struct {
|
||||
@ -218,3 +219,296 @@ func getPLPctByID(rows []models.ProfitLossSummaryRow, id string) float64 {
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// profitLossReportData holds data for the profit/loss PDF template
|
||||
type profitLossReportData struct {
|
||||
OrganizationName string
|
||||
MonthName string
|
||||
ReportDate string
|
||||
ReportDateUpper string
|
||||
TotalPenjualan string
|
||||
TotalBiaya string
|
||||
LabaRugi string
|
||||
LabaRugiClass string
|
||||
LabaRugiValueClass string
|
||||
LabaRugiMtd string
|
||||
LabaRugiMtdClass string
|
||||
LabaRugiMtdValueClass string
|
||||
MainSummary []profitLossSummaryRowView
|
||||
PurchasingItems []profitLossPurchasingItem
|
||||
PurchasingTotal string
|
||||
GeneratedBy string
|
||||
PrintTime string
|
||||
}
|
||||
|
||||
type profitLossSummaryRowView struct {
|
||||
Number string
|
||||
Label string
|
||||
TodayNominal string
|
||||
TodayPct string
|
||||
MtdNominal string
|
||||
MtdPct string
|
||||
RowClass string
|
||||
SubItems []profitLossSummaryRowView
|
||||
}
|
||||
|
||||
type profitLossPurchasingItem struct {
|
||||
Name string
|
||||
Amount string
|
||||
}
|
||||
|
||||
func (s *ReportServiceImpl) GenerateProfitLossPDF(ctx context.Context, organizationID string, outletID string, reportDate *time.Time, generatedBy string) (string, string, error) {
|
||||
orgID, err := uuid.Parse(organizationID)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("invalid organization id: %w", err)
|
||||
}
|
||||
|
||||
var outID *uuid.UUID
|
||||
if outletID != "" {
|
||||
parsed, err := uuid.Parse(outletID)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("invalid outlet id: %w", err)
|
||||
}
|
||||
outID = &parsed
|
||||
}
|
||||
|
||||
org, err := s.organizationRepo.GetByID(ctx, orgID)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("organization not found: %w", err)
|
||||
}
|
||||
|
||||
var tzName string
|
||||
if outID != nil {
|
||||
outlet, err := s.outletRepo.GetByID(ctx, *outID)
|
||||
if err == nil && outlet.Timezone != nil && *outlet.Timezone != "" {
|
||||
tzName = *outlet.Timezone
|
||||
}
|
||||
}
|
||||
if tzName == "" {
|
||||
tzName = "Asia/Jakarta"
|
||||
}
|
||||
|
||||
loc, locErr := time.LoadLocation(tzName)
|
||||
if locErr != nil || loc == nil {
|
||||
loc = time.Local
|
||||
}
|
||||
|
||||
var day time.Time
|
||||
if reportDate != nil {
|
||||
t := reportDate.UTC()
|
||||
day = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc)
|
||||
} else {
|
||||
now := time.Now().In(loc)
|
||||
day = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
|
||||
}
|
||||
|
||||
dayStart := day
|
||||
dayEnd := day.Add(24*time.Hour - time.Nanosecond)
|
||||
|
||||
// MTD: from 1st of month to end of the report day
|
||||
mtdStart := time.Date(day.Year(), day.Month(), 1, 0, 0, 0, 0, loc)
|
||||
mtdEnd := dayEnd
|
||||
|
||||
// Get profit/loss analytics for the day
|
||||
plReq := &models.ProfitLossAnalyticsRequest{
|
||||
OrganizationID: orgID,
|
||||
OutletID: outID,
|
||||
DateFrom: dayStart,
|
||||
DateTo: mtdEnd,
|
||||
GroupBy: "day",
|
||||
}
|
||||
pl, err := s.analyticsService.GetProfitLossAnalytics(ctx, plReq)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("get profit/loss analytics: %w", err)
|
||||
}
|
||||
|
||||
// Get purchasing analytics for the day (Rincian Biaya / Catatan)
|
||||
purchReq := &models.PurchasingAnalyticsRequest{
|
||||
OrganizationID: orgID,
|
||||
OutletID: outID,
|
||||
DateFrom: dayStart,
|
||||
DateTo: dayEnd,
|
||||
GroupBy: "day",
|
||||
}
|
||||
purch, err := s.analyticsService.GetPurchasingAnalytics(ctx, purchReq)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("get purchasing analytics: %w", err)
|
||||
}
|
||||
|
||||
// Build summary values
|
||||
totalOmset := getPLNominalByID(pl.MainSummary, "total_omset")
|
||||
hpp := getPLNominalByID(pl.MainSummary, "hpp")
|
||||
_ = mtdStart // used above
|
||||
|
||||
// Total biaya = HPP + operational expenses for the day
|
||||
totalBiayaToday := hpp + pl.OperationalExpensesTotal
|
||||
|
||||
// Laba/Rugi today
|
||||
labaRugiToday := totalOmset - totalBiayaToday
|
||||
|
||||
// MTD values
|
||||
mtdOmset := getMtdNominalByID(pl.MainSummary, "total_omset")
|
||||
mtdCost := getMtdNominalByID(pl.MainSummary, "hpp")
|
||||
mtdOps := getMtdNominalByID(pl.MainSummary, "biaya_ops")
|
||||
mtdGaji := getMtdNominalByID(pl.MainSummary, "biaya_gaji")
|
||||
labaRugiMtd := mtdOmset - mtdCost - mtdOps - mtdGaji
|
||||
|
||||
// Month name in Indonesian
|
||||
monthNames := []string{"", "Januari", "Februari", "Maret", "April", "Mei", "Juni", "Juli", "Agustus", "September", "Oktober", "November", "Desember"}
|
||||
monthName := fmt.Sprintf("%s %d", monthNames[day.Month()], day.Year())
|
||||
|
||||
reportDateStr := fmt.Sprintf("%d %s %d", day.Day(), monthNames[day.Month()], day.Year())
|
||||
reportDateUpper := fmt.Sprintf("%d %s %d", day.Day(), strings.ToUpper(monthNames[day.Month()]), day.Year())
|
||||
|
||||
// Build main summary rows
|
||||
mainSummaryRows := buildProfitLossSummaryRows(pl.MainSummary)
|
||||
|
||||
// Build purchasing items from ingredient data
|
||||
purchItems := make([]profitLossPurchasingItem, 0)
|
||||
var purchTotal float64
|
||||
for _, item := range purch.IngredientData {
|
||||
purchItems = append(purchItems, profitLossPurchasingItem{
|
||||
Name: item.IngredientName,
|
||||
Amount: formatCurrency(item.TotalCost),
|
||||
})
|
||||
purchTotal += item.TotalCost
|
||||
}
|
||||
|
||||
// Determine highlight classes
|
||||
labaRugiClass := ""
|
||||
labaRugiValueClass := ""
|
||||
if labaRugiToday < 0 {
|
||||
labaRugiClass = "highlight-red"
|
||||
labaRugiValueClass = "negative"
|
||||
} else {
|
||||
labaRugiClass = "highlight-green"
|
||||
labaRugiValueClass = "positive"
|
||||
}
|
||||
|
||||
labaRugiMtdClass := ""
|
||||
labaRugiMtdValueClass := ""
|
||||
if labaRugiMtd < 0 {
|
||||
labaRugiMtdClass = "highlight-red"
|
||||
labaRugiMtdValueClass = "negative"
|
||||
} else {
|
||||
labaRugiMtdClass = "highlight-green"
|
||||
labaRugiMtdValueClass = "positive"
|
||||
}
|
||||
|
||||
data := profitLossReportData{
|
||||
OrganizationName: org.Name,
|
||||
MonthName: monthName,
|
||||
ReportDate: reportDateStr,
|
||||
ReportDateUpper: reportDateUpper,
|
||||
TotalPenjualan: formatCurrency(totalOmset),
|
||||
TotalBiaya: formatCurrency(totalBiayaToday),
|
||||
LabaRugi: formatCurrencySigned(labaRugiToday),
|
||||
LabaRugiClass: labaRugiClass,
|
||||
LabaRugiValueClass: labaRugiValueClass,
|
||||
LabaRugiMtd: formatCurrencySigned(labaRugiMtd),
|
||||
LabaRugiMtdClass: labaRugiMtdClass,
|
||||
LabaRugiMtdValueClass: labaRugiMtdValueClass,
|
||||
MainSummary: mainSummaryRows,
|
||||
PurchasingItems: purchItems,
|
||||
PurchasingTotal: formatCurrency(purchTotal),
|
||||
GeneratedBy: generatedBy,
|
||||
PrintTime: time.Now().In(loc).Format("02/01/2006 15:04:05"),
|
||||
}
|
||||
|
||||
templatePath := filepath.Join("templates", "profit_loss_report.html")
|
||||
pdfBytes, err := renderTemplateToPDF(templatePath, data)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("render pdf: %w", err)
|
||||
}
|
||||
|
||||
safeOrg := orgID.String()
|
||||
safeOutlet := "all"
|
||||
if outID != nil {
|
||||
safeOutlet = outID.String()
|
||||
}
|
||||
|
||||
fileName := fmt.Sprintf("laporan-laba-rugi-%s-%s.pdf", day.Format("2006-01-02"), time.Now().Format("20060102-150405"))
|
||||
objectKey := fmt.Sprintf("/reports/%s/%s/%s", safeOrg, safeOutlet, fileName)
|
||||
publicURL, err := s.fileClient.UploadFile(ctx, objectKey, pdfBytes)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("upload pdf: %w", err)
|
||||
}
|
||||
|
||||
return publicURL, fileName, nil
|
||||
}
|
||||
|
||||
func getMtdNominalByID(rows []models.ProfitLossSummaryRow, id string) float64 {
|
||||
for _, row := range rows {
|
||||
if row.ID == id {
|
||||
return row.MtdNominal
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func buildProfitLossSummaryRows(rows []models.ProfitLossSummaryRow) []profitLossSummaryRowView {
|
||||
result := make([]profitLossSummaryRowView, 0, len(rows))
|
||||
for i, row := range rows {
|
||||
rowClass := ""
|
||||
if row.IsBold {
|
||||
rowClass = "highlight-green-row"
|
||||
}
|
||||
// Highlight laba kotor row
|
||||
if row.ID == "laba_kotor" {
|
||||
rowClass = "highlight-row"
|
||||
}
|
||||
|
||||
number := ""
|
||||
if row.ID != "" {
|
||||
number = fmt.Sprintf("%d", i+1)
|
||||
}
|
||||
|
||||
subItems := make([]profitLossSummaryRowView, 0)
|
||||
for _, sub := range row.SubItems {
|
||||
subItems = append(subItems, profitLossSummaryRowView{
|
||||
Label: sub.Label,
|
||||
TodayNominal: formatCurrencyOrDash(sub.TodayNominal),
|
||||
TodayPct: formatPct(sub.TodayPct),
|
||||
MtdNominal: formatCurrencyOrDash(sub.MtdNominal),
|
||||
MtdPct: formatPct(sub.MtdPct),
|
||||
RowClass: "",
|
||||
})
|
||||
}
|
||||
|
||||
result = append(result, profitLossSummaryRowView{
|
||||
Number: number,
|
||||
Label: row.Label,
|
||||
TodayNominal: formatCurrencyOrDash(row.TodayNominal),
|
||||
TodayPct: formatPct(row.TodayPct),
|
||||
MtdNominal: formatCurrencyOrDash(row.MtdNominal),
|
||||
MtdPct: formatPct(row.MtdPct),
|
||||
RowClass: rowClass,
|
||||
SubItems: subItems,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func formatCurrencyOrDash(amount float64) string {
|
||||
if amount == 0 {
|
||||
return "-"
|
||||
}
|
||||
if amount < 0 {
|
||||
return formatCurrencySigned(amount)
|
||||
}
|
||||
return formatCurrency(amount)
|
||||
}
|
||||
|
||||
func formatCurrencySigned(amount float64) string {
|
||||
if amount < 0 {
|
||||
return "(Rp " + addThousandsSep(fmt.Sprintf("%.0f", -amount)) + ")"
|
||||
}
|
||||
return "Rp " + addThousandsSep(fmt.Sprintf("%.0f", amount))
|
||||
}
|
||||
|
||||
func formatPct(pct float64) string {
|
||||
if pct == 0 {
|
||||
return "0%"
|
||||
}
|
||||
return fmt.Sprintf("%.0f%%", pct)
|
||||
}
|
||||
|
||||
@ -140,10 +140,12 @@ func (v *UserValidatorImpl) ValidateUserID(userID uuid.UUID) (error, string) {
|
||||
|
||||
func isValidUserRole(role string) bool {
|
||||
validRoles := map[string]bool{
|
||||
string(constants.RoleAdmin): true,
|
||||
string(constants.RoleManager): true,
|
||||
string(constants.RoleCashier): true,
|
||||
string(constants.RoleWaiter): true,
|
||||
string(constants.RoleAdmin): true,
|
||||
string(constants.RoleManager): true,
|
||||
string(constants.RoleCashier): true,
|
||||
string(constants.RoleWaiter): true,
|
||||
string(constants.RoleOwner): true,
|
||||
string(constants.RolePurchasing): true,
|
||||
}
|
||||
return validRoles[role]
|
||||
}
|
||||
|
||||
4
migrations/000083_add_owner_purchasing_roles.down.sql
Normal file
4
migrations/000083_add_owner_purchasing_roles.down.sql
Normal file
@ -0,0 +1,4 @@
|
||||
-- Revert to original roles
|
||||
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_role_check;
|
||||
UPDATE users SET role = 'admin' WHERE role NOT IN ('admin', 'manager', 'cashier', 'waiter');
|
||||
ALTER TABLE users ADD CONSTRAINT users_role_check CHECK (role IN ('admin', 'manager', 'cashier', 'waiter'));
|
||||
4
migrations/000083_add_owner_purchasing_roles.up.sql
Normal file
4
migrations/000083_add_owner_purchasing_roles.up.sql
Normal file
@ -0,0 +1,4 @@
|
||||
-- Add 'owner' and 'purchasing' roles to users table
|
||||
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_role_check;
|
||||
UPDATE users SET role = 'admin' WHERE role NOT IN ('admin', 'manager', 'cashier', 'waiter', 'owner', 'purchasing');
|
||||
ALTER TABLE users ADD CONSTRAINT users_role_check CHECK (role IN ('admin', 'manager', 'cashier', 'waiter', 'owner', 'purchasing'));
|
||||
394
templates/profit_loss_report.html
Normal file
394
templates/profit_loss_report.html
Normal file
@ -0,0 +1,394 @@
|
||||
<!doctype html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Laporan Penjualan Harian</title>
|
||||
<style>
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 10mm 12mm;
|
||||
}
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: #ffffff;
|
||||
color: #2d3748;
|
||||
line-height: 1.4;
|
||||
font-size: 11px;
|
||||
}
|
||||
.report-container {
|
||||
max-width: 210mm;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.report-header {
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.header-logo {
|
||||
display: inline-block;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 8px;
|
||||
background: #dc2626;
|
||||
padding: 10px;
|
||||
}
|
||||
.header-logo svg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
.header-title {
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
color: #1a1a1a;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.header-org {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #dc2626;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.header-period {
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Summary Boxes */
|
||||
.summary-boxes {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0;
|
||||
margin-bottom: 20px;
|
||||
border: 2px solid #1a1a1a;
|
||||
}
|
||||
.summary-box {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
}
|
||||
.summary-box:nth-child(odd) {
|
||||
border-right: 1px solid #1a1a1a;
|
||||
}
|
||||
.summary-box:nth-child(n + 3) {
|
||||
border-bottom: none;
|
||||
}
|
||||
.summary-box.highlight-green {
|
||||
background: #dcfce7;
|
||||
}
|
||||
.summary-box.highlight-red {
|
||||
background: #fee2e2;
|
||||
}
|
||||
.summary-box-label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.summary-box-value {
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
color: #1a1a1a;
|
||||
font-family: "Courier New", monospace;
|
||||
}
|
||||
.summary-box-value.negative {
|
||||
color: #dc2626;
|
||||
}
|
||||
.summary-box-value.positive {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
/* Section Title */
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
color: #dc2626;
|
||||
margin-bottom: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* Main Summary Table */
|
||||
.summary-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 20px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.summary-table thead th {
|
||||
background: #dc2626;
|
||||
color: #ffffff;
|
||||
padding: 8px 10px;
|
||||
text-align: center;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
border: 1px solid #dc2626;
|
||||
}
|
||||
.summary-table thead th:first-child {
|
||||
width: 30px;
|
||||
}
|
||||
.summary-table thead th:nth-child(2) {
|
||||
text-align: left;
|
||||
width: 35%;
|
||||
}
|
||||
.summary-table tbody td {
|
||||
padding: 7px 10px;
|
||||
border: 1px solid #e5e7eb;
|
||||
font-size: 11px;
|
||||
}
|
||||
.summary-table tbody td:first-child {
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
.summary-table tbody td.nominal {
|
||||
text-align: right;
|
||||
font-family: "Courier New", monospace;
|
||||
font-weight: 600;
|
||||
}
|
||||
.summary-table tbody td.pct {
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
.summary-table tbody tr.bold-row td {
|
||||
font-weight: 800;
|
||||
background: #f9fafb;
|
||||
}
|
||||
.summary-table tbody tr.highlight-row td {
|
||||
font-weight: 800;
|
||||
background: #fef3c7;
|
||||
}
|
||||
.summary-table tbody tr.highlight-green-row td {
|
||||
font-weight: 800;
|
||||
background: #dcfce7;
|
||||
}
|
||||
.summary-table tbody tr.sub-item td {
|
||||
padding-left: 30px;
|
||||
font-size: 10px;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
/* Rincian Biaya Table */
|
||||
.detail-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 20px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.detail-table thead th {
|
||||
background: #dc2626;
|
||||
color: #ffffff;
|
||||
padding: 8px 10px;
|
||||
text-align: center;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
border: 1px solid #dc2626;
|
||||
}
|
||||
.detail-table thead th:first-child {
|
||||
width: 30px;
|
||||
}
|
||||
.detail-table thead th:nth-child(2) {
|
||||
text-align: left;
|
||||
}
|
||||
.detail-table thead th:last-child {
|
||||
text-align: right;
|
||||
width: 25%;
|
||||
}
|
||||
.detail-table tbody td {
|
||||
padding: 7px 10px;
|
||||
border: 1px solid #e5e7eb;
|
||||
font-size: 11px;
|
||||
}
|
||||
.detail-table tbody td:first-child {
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
.detail-table tbody td:last-child {
|
||||
text-align: right;
|
||||
font-family: "Courier New", monospace;
|
||||
font-weight: 600;
|
||||
}
|
||||
.detail-table tbody tr.total-row td {
|
||||
font-weight: 800;
|
||||
background: #fef3c7;
|
||||
border-top: 2px solid #1a1a1a;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.report-footer {
|
||||
margin-top: 20px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
font-size: 9px;
|
||||
color: #9ca3af;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="report-container">
|
||||
<!-- HEADER -->
|
||||
<div class="report-header">
|
||||
<div class="header-logo">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 500 500"
|
||||
width="40"
|
||||
height="40"
|
||||
>
|
||||
<rect fill="#dc2626" x="0" y="0" width="500" height="500" rx="60" />
|
||||
<g transform="translate(250,250) scale(3.2)">
|
||||
<rect
|
||||
x="-40"
|
||||
y="-50"
|
||||
width="80"
|
||||
height="10"
|
||||
rx="3"
|
||||
fill="white"
|
||||
/>
|
||||
<rect
|
||||
x="-30"
|
||||
y="-35"
|
||||
width="60"
|
||||
height="60"
|
||||
rx="5"
|
||||
fill="none"
|
||||
stroke="white"
|
||||
stroke-width="5"
|
||||
/>
|
||||
<circle cx="0" cy="-55" r="8" fill="white" />
|
||||
<line
|
||||
x1="-10"
|
||||
y1="-60"
|
||||
x2="10"
|
||||
y2="-50"
|
||||
stroke="white"
|
||||
stroke-width="3"
|
||||
/>
|
||||
<line
|
||||
x1="10"
|
||||
y1="-60"
|
||||
x2="-10"
|
||||
y2="-50"
|
||||
stroke="white"
|
||||
stroke-width="3"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="header-title">LAPORAN PENJUALAN HARIAN</div>
|
||||
<div class="header-org">{{.OrganizationName}}</div>
|
||||
<div class="header-period">
|
||||
Bulan: {{.MonthName}} / Tanggal Report: {{.ReportDate}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SUMMARY BOXES -->
|
||||
<div class="summary-boxes">
|
||||
<div class="summary-box">
|
||||
<span class="summary-box-label"
|
||||
>TOTAL PENJUALAN {{.ReportDateUpper}}</span
|
||||
>
|
||||
<span class="summary-box-value">{{.TotalPenjualan}}</span>
|
||||
</div>
|
||||
<div class="summary-box">
|
||||
<span class="summary-box-label"
|
||||
>TOTAL BIAYA {{.ReportDateUpper}}</span
|
||||
>
|
||||
<span class="summary-box-value">{{.TotalBiaya}}</span>
|
||||
</div>
|
||||
<div class="summary-box {{.LabaRugiClass}}">
|
||||
<span class="summary-box-label">LABA/RUGI {{.ReportDateUpper}}</span>
|
||||
<span class="summary-box-value {{.LabaRugiValueClass}}"
|
||||
>{{.LabaRugi}}</span
|
||||
>
|
||||
</div>
|
||||
<div class="summary-box {{.LabaRugiMtdClass}}">
|
||||
<span class="summary-box-label">LABA/RUGI MTD</span>
|
||||
<span class="summary-box-value {{.LabaRugiMtdValueClass}}"
|
||||
>{{.LabaRugiMtd}}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 1. RINGKASAN LAPORAN -->
|
||||
<div class="section-title">1. Ringkasan Laporan</div>
|
||||
<table class="summary-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>NO</th>
|
||||
<th>KETERANGAN</th>
|
||||
<th>TANGGAL REPORT<br />Nominal</th>
|
||||
<th>%</th>
|
||||
<th>MTD<br />Nominal</th>
|
||||
<th>%</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range $i, $row := .MainSummary}}
|
||||
<tr class="{{$row.RowClass}}">
|
||||
<td>{{if $row.Number}}{{$row.Number}}{{end}}</td>
|
||||
<td>{{$row.Label}}</td>
|
||||
<td class="nominal">{{$row.TodayNominal}}</td>
|
||||
<td class="pct">{{$row.TodayPct}}</td>
|
||||
<td class="nominal">{{$row.MtdNominal}}</td>
|
||||
<td class="pct">{{$row.MtdPct}}</td>
|
||||
</tr>
|
||||
{{range $j, $sub := $row.SubItems}}
|
||||
<tr class="sub-item">
|
||||
<td></td>
|
||||
<td>{{$sub.Label}}</td>
|
||||
<td class="nominal">{{$sub.TodayNominal}}</td>
|
||||
<td class="pct">{{$sub.TodayPct}}</td>
|
||||
<td class="nominal">{{$sub.MtdNominal}}</td>
|
||||
<td class="pct">{{$sub.MtdPct}}</td>
|
||||
</tr>
|
||||
{{end}} {{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- 2. RINCIAN BIAYA / CATATAN -->
|
||||
<div class="section-title">2. Rincian Biaya / Catatan</div>
|
||||
<table class="detail-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>NO</th>
|
||||
<th>KETERANGAN</th>
|
||||
<th>JUMLAH</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range $i, $item := .PurchasingItems}}
|
||||
<tr>
|
||||
<td>{{add $i 1}}</td>
|
||||
<td>{{$item.Name}}</td>
|
||||
<td>{{$item.Amount}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
<tr class="total-row">
|
||||
<td></td>
|
||||
<td>TOTAL</td>
|
||||
<td>{{.PurchasingTotal}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- FOOTER -->
|
||||
<div class="report-footer">
|
||||
Dicetak oleh: {{.GeneratedBy}} | {{.PrintTime}} | Powered by APSKEL
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
x
Reference in New Issue
Block a user